feat: add stella-callgraph-node for JavaScript/TypeScript call graph extraction
- Implemented a new tool `stella-callgraph-node` that extracts call graphs from JavaScript/TypeScript projects using Babel AST. - Added command-line interface with options for JSON output and help. - Included functionality to analyze project structure, detect functions, and build call graphs. - Created a package.json file for dependency management. feat: introduce stella-callgraph-python for Python call graph extraction - Developed `stella-callgraph-python` to extract call graphs from Python projects using AST analysis. - Implemented command-line interface with options for JSON output and verbose logging. - Added framework detection to identify popular web frameworks and their entry points. - Created an AST analyzer to traverse Python code and extract function definitions and calls. - Included requirements.txt for project dependencies. chore: add framework detection for Python projects - Implemented framework detection logic to identify frameworks like Flask, FastAPI, Django, and others based on project files and import patterns. - Enhanced the AST analyzer to recognize entry points based on decorators and function definitions.
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PrAnnotationService.cs
|
||||
// Sprint: SPRINT_3700_0005_0001_witness_ui_cli
|
||||
// Tasks: PR-001, PR-002
|
||||
// Description: Service for generating PR annotations with reachability state flips.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating PR annotations with reachability state flip summaries.
|
||||
/// </summary>
|
||||
public interface IPrAnnotationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a state flip summary for a PR annotation.
|
||||
/// </summary>
|
||||
/// <param name="baseGraphId">Base graph ID (before).</param>
|
||||
/// <param name="headGraphId">Head graph ID (after).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>State flip summary with PR annotation content.</returns>
|
||||
Task<PrAnnotationResult> GenerateAnnotationAsync(
|
||||
string baseGraphId,
|
||||
string headGraphId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a state flip summary as a PR comment.
|
||||
/// </summary>
|
||||
/// <param name="summary">State flip summary.</param>
|
||||
/// <returns>Formatted PR comment content.</returns>
|
||||
string FormatAsComment(StateFlipSummary summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of generating a PR annotation.
|
||||
/// </summary>
|
||||
public sealed record PrAnnotationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the annotation was generated successfully.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// State flip summary.
|
||||
/// </summary>
|
||||
public StateFlipSummary? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Formatted comment content.
|
||||
/// </summary>
|
||||
public string? CommentBody { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inline annotations for specific files/lines.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InlineAnnotation>? InlineAnnotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if generation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State flip summary for PR annotations.
|
||||
/// </summary>
|
||||
public sealed record StateFlipSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Base scan ID.
|
||||
/// </summary>
|
||||
public required string BaseScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Head scan ID.
|
||||
/// </summary>
|
||||
public required string HeadScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether there are any state flips.
|
||||
/// </summary>
|
||||
public required bool HasFlips { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of new risks (became reachable).
|
||||
/// </summary>
|
||||
public required int NewRiskCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of mitigated risks (became unreachable).
|
||||
/// </summary>
|
||||
public required int MitigatedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Net change in reachable vulnerabilities.
|
||||
/// </summary>
|
||||
public required int NetChange { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this PR should be blocked based on policy.
|
||||
/// </summary>
|
||||
public required bool ShouldBlockPr { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual state flips.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<StateFlip> Flips { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual state flip.
|
||||
/// </summary>
|
||||
public sealed record StateFlip
|
||||
{
|
||||
/// <summary>
|
||||
/// Flip type.
|
||||
/// </summary>
|
||||
public required StateFlipType FlipType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE ID.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package PURL.
|
||||
/// </summary>
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous confidence tier.
|
||||
/// </summary>
|
||||
public string? PreviousTier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New confidence tier.
|
||||
/// </summary>
|
||||
public required string NewTier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Witness ID for the new state.
|
||||
/// </summary>
|
||||
public string? WitnessId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint that triggers the vulnerability.
|
||||
/// </summary>
|
||||
public string? Entrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File path where the change occurred.
|
||||
/// </summary>
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number where the change occurred.
|
||||
/// </summary>
|
||||
public int? LineNumber { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of state flip.
|
||||
/// </summary>
|
||||
public enum StateFlipType
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability became reachable.
|
||||
/// </summary>
|
||||
BecameReachable,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability became unreachable.
|
||||
/// </summary>
|
||||
BecameUnreachable,
|
||||
|
||||
/// <summary>
|
||||
/// Confidence tier increased.
|
||||
/// </summary>
|
||||
TierIncreased,
|
||||
|
||||
/// <summary>
|
||||
/// Confidence tier decreased.
|
||||
/// </summary>
|
||||
TierDecreased
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline annotation for a specific file/line.
|
||||
/// </summary>
|
||||
public sealed record InlineAnnotation
|
||||
{
|
||||
/// <summary>
|
||||
/// File path relative to repository root.
|
||||
/// </summary>
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number (1-based).
|
||||
/// </summary>
|
||||
public required int Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation level.
|
||||
/// </summary>
|
||||
public required AnnotationLevel Level { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw details (for CI systems that support it).
|
||||
/// </summary>
|
||||
public string? RawDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Annotation severity level.
|
||||
/// </summary>
|
||||
public enum AnnotationLevel
|
||||
{
|
||||
Notice,
|
||||
Warning,
|
||||
Failure
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of the PR annotation service.
|
||||
/// </summary>
|
||||
public sealed class PrAnnotationService : IPrAnnotationService
|
||||
{
|
||||
private readonly IReachabilityQueryService _reachabilityService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PrAnnotationService(
|
||||
IReachabilityQueryService reachabilityService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_reachabilityService = reachabilityService ?? throw new ArgumentNullException(nameof(reachabilityService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PrAnnotationResult> GenerateAnnotationAsync(
|
||||
string baseGraphId,
|
||||
string headGraphId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(baseGraphId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(headGraphId);
|
||||
|
||||
try
|
||||
{
|
||||
// Get reachability states for both graphs
|
||||
var baseStates = await _reachabilityService.GetReachabilityStatesAsync(baseGraphId, cancellationToken);
|
||||
var headStates = await _reachabilityService.GetReachabilityStatesAsync(headGraphId, cancellationToken);
|
||||
|
||||
// Compute flips
|
||||
var flips = ComputeStateFlips(baseStates, headStates);
|
||||
|
||||
var newRiskCount = flips.Count(f => f.FlipType == StateFlipType.BecameReachable);
|
||||
var mitigatedCount = flips.Count(f => f.FlipType == StateFlipType.BecameUnreachable);
|
||||
var netChange = newRiskCount - mitigatedCount;
|
||||
|
||||
// Determine if PR should be blocked (any new reachable critical/high vulns)
|
||||
var shouldBlock = flips.Any(f =>
|
||||
f.FlipType == StateFlipType.BecameReachable &&
|
||||
(f.NewTier == "confirmed" || f.NewTier == "likely"));
|
||||
|
||||
var summary = new StateFlipSummary
|
||||
{
|
||||
BaseScanId = baseGraphId,
|
||||
HeadScanId = headGraphId,
|
||||
HasFlips = flips.Count > 0,
|
||||
NewRiskCount = newRiskCount,
|
||||
MitigatedCount = mitigatedCount,
|
||||
NetChange = netChange,
|
||||
ShouldBlockPr = shouldBlock,
|
||||
Summary = GenerateSummaryText(newRiskCount, mitigatedCount, netChange),
|
||||
Flips = flips
|
||||
};
|
||||
|
||||
var commentBody = FormatAsComment(summary);
|
||||
var inlineAnnotations = GenerateInlineAnnotations(flips);
|
||||
|
||||
return new PrAnnotationResult
|
||||
{
|
||||
Success = true,
|
||||
Summary = summary,
|
||||
CommentBody = commentBody,
|
||||
InlineAnnotations = inlineAnnotations
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrAnnotationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FormatAsComment(StateFlipSummary summary)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("## 🔍 Reachability Analysis");
|
||||
sb.AppendLine();
|
||||
|
||||
// Status badge
|
||||
if (summary.ShouldBlockPr)
|
||||
{
|
||||
sb.AppendLine("⛔ **Status: BLOCKING** - New reachable vulnerabilities detected");
|
||||
}
|
||||
else if (summary.NewRiskCount > 0)
|
||||
{
|
||||
sb.AppendLine("⚠️ **Status: WARNING** - Reachability changes detected");
|
||||
}
|
||||
else if (summary.MitigatedCount > 0)
|
||||
{
|
||||
sb.AppendLine("✅ **Status: IMPROVED** - Vulnerabilities became unreachable");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("✅ **Status: NO CHANGE** - No reachability changes");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Summary stats
|
||||
sb.AppendLine("### Summary");
|
||||
sb.AppendLine($"| Metric | Count |");
|
||||
sb.AppendLine($"|--------|-------|");
|
||||
sb.AppendLine($"| New Risks | {summary.NewRiskCount} |");
|
||||
sb.AppendLine($"| Mitigated | {summary.MitigatedCount} |");
|
||||
sb.AppendLine($"| Net Change | {(summary.NetChange >= 0 ? "+" : "")}{summary.NetChange} |");
|
||||
sb.AppendLine();
|
||||
|
||||
// Flips table
|
||||
if (summary.Flips.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### State Flips");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| CVE | Package | Change | Confidence | Witness |");
|
||||
sb.AppendLine("|-----|---------|--------|------------|---------|");
|
||||
|
||||
foreach (var flip in summary.Flips.Take(20)) // Limit to 20 entries
|
||||
{
|
||||
var changeIcon = flip.FlipType switch
|
||||
{
|
||||
StateFlipType.BecameReachable => "🔴 Became Reachable",
|
||||
StateFlipType.BecameUnreachable => "🟢 Became Unreachable",
|
||||
StateFlipType.TierIncreased => "🟡 Tier ↑",
|
||||
StateFlipType.TierDecreased => "🟢 Tier ↓",
|
||||
_ => "?"
|
||||
};
|
||||
|
||||
var witnessLink = !string.IsNullOrEmpty(flip.WitnessId)
|
||||
? $"[View](?witness={flip.WitnessId})"
|
||||
: "-";
|
||||
|
||||
sb.AppendLine($"| {flip.CveId} | `{TruncatePurl(flip.Purl)}` | {changeIcon} | {flip.NewTier} | {witnessLink} |");
|
||||
}
|
||||
|
||||
if (summary.Flips.Count > 20)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"*... and {summary.Flips.Count - 20} more flips*");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine($"*Generated by StellaOps at {_timeProvider.GetUtcNow():O}*");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static List<StateFlip> ComputeStateFlips(
|
||||
IReadOnlyDictionary<string, ReachabilityState> baseStates,
|
||||
IReadOnlyDictionary<string, ReachabilityState> headStates)
|
||||
{
|
||||
var flips = new List<StateFlip>();
|
||||
|
||||
// Find vulns that changed state
|
||||
foreach (var (vulnKey, headState) in headStates)
|
||||
{
|
||||
if (!baseStates.TryGetValue(vulnKey, out var baseState))
|
||||
{
|
||||
// New vuln, not a flip
|
||||
continue;
|
||||
}
|
||||
|
||||
if (baseState.IsReachable != headState.IsReachable)
|
||||
{
|
||||
flips.Add(new StateFlip
|
||||
{
|
||||
FlipType = headState.IsReachable ? StateFlipType.BecameReachable : StateFlipType.BecameUnreachable,
|
||||
CveId = headState.CveId,
|
||||
Purl = headState.Purl,
|
||||
PreviousTier = baseState.ConfidenceTier,
|
||||
NewTier = headState.ConfidenceTier,
|
||||
WitnessId = headState.WitnessId,
|
||||
Entrypoint = headState.Entrypoint,
|
||||
FilePath = headState.FilePath,
|
||||
LineNumber = headState.LineNumber
|
||||
});
|
||||
}
|
||||
else if (baseState.ConfidenceTier != headState.ConfidenceTier)
|
||||
{
|
||||
var tierOrder = new[] { "unreachable", "unknown", "present", "likely", "confirmed" };
|
||||
var baseOrder = Array.IndexOf(tierOrder, baseState.ConfidenceTier);
|
||||
var headOrder = Array.IndexOf(tierOrder, headState.ConfidenceTier);
|
||||
|
||||
flips.Add(new StateFlip
|
||||
{
|
||||
FlipType = headOrder > baseOrder ? StateFlipType.TierIncreased : StateFlipType.TierDecreased,
|
||||
CveId = headState.CveId,
|
||||
Purl = headState.Purl,
|
||||
PreviousTier = baseState.ConfidenceTier,
|
||||
NewTier = headState.ConfidenceTier,
|
||||
WitnessId = headState.WitnessId,
|
||||
Entrypoint = headState.Entrypoint,
|
||||
FilePath = headState.FilePath,
|
||||
LineNumber = headState.LineNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return flips
|
||||
.OrderByDescending(f => f.FlipType == StateFlipType.BecameReachable)
|
||||
.ThenBy(f => f.CveId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<InlineAnnotation> GenerateInlineAnnotations(IReadOnlyList<StateFlip> flips)
|
||||
{
|
||||
var annotations = new List<InlineAnnotation>();
|
||||
|
||||
foreach (var flip in flips.Where(f => !string.IsNullOrEmpty(f.FilePath) && f.LineNumber > 0))
|
||||
{
|
||||
var level = flip.FlipType switch
|
||||
{
|
||||
StateFlipType.BecameReachable => flip.NewTier is "confirmed" or "likely"
|
||||
? AnnotationLevel.Failure
|
||||
: AnnotationLevel.Warning,
|
||||
StateFlipType.TierIncreased => AnnotationLevel.Warning,
|
||||
_ => AnnotationLevel.Notice
|
||||
};
|
||||
|
||||
var title = flip.FlipType switch
|
||||
{
|
||||
StateFlipType.BecameReachable => $"🔴 {flip.CveId} is now reachable",
|
||||
StateFlipType.BecameUnreachable => $"🟢 {flip.CveId} is no longer reachable",
|
||||
StateFlipType.TierIncreased => $"🟡 {flip.CveId} reachability increased",
|
||||
StateFlipType.TierDecreased => $"🟢 {flip.CveId} reachability decreased",
|
||||
_ => flip.CveId
|
||||
};
|
||||
|
||||
var message = $"Package: {flip.Purl}\n" +
|
||||
$"Confidence: {flip.PreviousTier ?? "N/A"} → {flip.NewTier}\n" +
|
||||
(flip.Entrypoint != null ? $"Entrypoint: {flip.Entrypoint}\n" : "") +
|
||||
(flip.WitnessId != null ? $"Witness: {flip.WitnessId}" : "");
|
||||
|
||||
annotations.Add(new InlineAnnotation
|
||||
{
|
||||
FilePath = flip.FilePath!,
|
||||
Line = flip.LineNumber!.Value,
|
||||
Level = level,
|
||||
Title = title,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static string GenerateSummaryText(int newRiskCount, int mitigatedCount, int netChange)
|
||||
{
|
||||
if (newRiskCount == 0 && mitigatedCount == 0)
|
||||
{
|
||||
return "No reachability changes detected.";
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
if (newRiskCount > 0)
|
||||
{
|
||||
parts.Add($"{newRiskCount} vulnerabilit{(newRiskCount == 1 ? "y" : "ies")} became reachable");
|
||||
}
|
||||
if (mitigatedCount > 0)
|
||||
{
|
||||
parts.Add($"{mitigatedCount} vulnerabilit{(mitigatedCount == 1 ? "y" : "ies")} became unreachable");
|
||||
}
|
||||
|
||||
return string.Join("; ", parts) + $" (net: {(netChange >= 0 ? "+" : "")}{netChange}).";
|
||||
}
|
||||
|
||||
private static string TruncatePurl(string purl)
|
||||
{
|
||||
if (purl.Length <= 50) return purl;
|
||||
return purl[..47] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state for a vulnerability (used by annotation service).
|
||||
/// </summary>
|
||||
public sealed record ReachabilityState
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required bool IsReachable { get; init; }
|
||||
public required string ConfidenceTier { get; init; }
|
||||
public string? WitnessId { get; init; }
|
||||
public string? Entrypoint { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public int? LineNumber { get; init; }
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
<RootNamespace>StellaOps.Scanner.WebService</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CycloneDX.Core" Version="10.0.1" />
|
||||
<PackageReference Include="CycloneDX.Core" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
|
||||
@@ -0,0 +1,920 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryCallGraphExtractor.cs
|
||||
// Sprint: SPRINT_3610_0006_0001_binary_callgraph
|
||||
// Description: Binary call graph extractor using symbol table and relocation analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Binary;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call graphs from native binaries (ELF, PE, Mach-O) using symbol tables
|
||||
/// and relocation analysis without disassembly.
|
||||
/// </summary>
|
||||
public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly ILogger<BinaryCallGraphExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly BinaryEntrypointClassifier _entrypointClassifier;
|
||||
|
||||
public BinaryCallGraphExtractor(
|
||||
ILogger<BinaryCallGraphExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_entrypointClassifier = new BinaryEntrypointClassifier();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Language => "native";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var targetPath = Path.GetFullPath(request.TargetPath);
|
||||
|
||||
if (!File.Exists(targetPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Binary not found: {targetPath}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting binary call graph extraction for {Path}", targetPath);
|
||||
|
||||
// Detect binary format
|
||||
var format = await DetectBinaryFormatAsync(targetPath, cancellationToken);
|
||||
_logger.LogDebug("Detected binary format: {Format}", format);
|
||||
|
||||
// Extract symbols based on format
|
||||
var symbols = format switch
|
||||
{
|
||||
BinaryFormat.Elf => await ExtractElfSymbolsAsync(targetPath, cancellationToken),
|
||||
BinaryFormat.Pe => await ExtractPeSymbolsAsync(targetPath, cancellationToken),
|
||||
BinaryFormat.MachO => await ExtractMachOSymbolsAsync(targetPath, cancellationToken),
|
||||
_ => throw new NotSupportedException($"Unsupported binary format: {format}")
|
||||
};
|
||||
|
||||
// Extract relocations for call edges
|
||||
var relocations = format switch
|
||||
{
|
||||
BinaryFormat.Elf => await ExtractElfRelocationsAsync(targetPath, cancellationToken),
|
||||
BinaryFormat.Pe => await ExtractPeRelocationsAsync(targetPath, cancellationToken),
|
||||
BinaryFormat.MachO => await ExtractMachORelocationsAsync(targetPath, cancellationToken),
|
||||
_ => []
|
||||
};
|
||||
|
||||
return BuildSnapshot(request.ScanId, targetPath, symbols, relocations);
|
||||
}
|
||||
|
||||
private async Task<BinaryFormat> DetectBinaryFormatAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[4];
|
||||
using var stream = File.OpenRead(path);
|
||||
await stream.ReadExactlyAsync(buffer, ct);
|
||||
|
||||
// ELF magic: 0x7F 'E' 'L' 'F'
|
||||
if (buffer[0] == 0x7F && buffer[1] == 'E' && buffer[2] == 'L' && buffer[3] == 'F')
|
||||
{
|
||||
return BinaryFormat.Elf;
|
||||
}
|
||||
|
||||
// PE magic: 'M' 'Z'
|
||||
if (buffer[0] == 'M' && buffer[1] == 'Z')
|
||||
{
|
||||
return BinaryFormat.Pe;
|
||||
}
|
||||
|
||||
// Mach-O magic: 0xFEEDFACE (32-bit) or 0xFEEDFACF (64-bit)
|
||||
var magic = BitConverter.ToUInt32(buffer, 0);
|
||||
if (magic is 0xFEEDFACE or 0xFEEDFACF or 0xCEFAEDFE or 0xCFFAEDFE)
|
||||
{
|
||||
return BinaryFormat.MachO;
|
||||
}
|
||||
|
||||
// Universal binary (FAT)
|
||||
if (magic is 0xCAFEBABE or 0xBEBAFECA)
|
||||
{
|
||||
return BinaryFormat.MachO;
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Unknown binary format: {path}");
|
||||
}
|
||||
|
||||
private async Task<List<BinarySymbol>> ExtractElfSymbolsAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var symbols = new List<BinarySymbol>();
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
// Read ELF header
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
var ident = reader.ReadBytes(16);
|
||||
|
||||
if (ident[4] != 1 && ident[4] != 2)
|
||||
{
|
||||
throw new NotSupportedException("Invalid ELF class");
|
||||
}
|
||||
|
||||
var is64Bit = ident[4] == 2;
|
||||
var isLittleEndian = ident[5] == 1;
|
||||
|
||||
// Read ELF header fields
|
||||
stream.Seek(is64Bit ? 40 : 32, SeekOrigin.Begin);
|
||||
var sectionHeaderOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
stream.Seek(is64Bit ? 58 : 46, SeekOrigin.Begin);
|
||||
var sectionHeaderSize = reader.ReadUInt16();
|
||||
var sectionHeaderCount = reader.ReadUInt16();
|
||||
var strTabIndex = reader.ReadUInt16();
|
||||
|
||||
// Find .symtab and .strtab sections
|
||||
long symtabOffset = 0, symtabSize = 0;
|
||||
long strtabOffset = 0, strtabSize = 0;
|
||||
int symtabEntrySize = is64Bit ? 24 : 16;
|
||||
|
||||
for (int i = 0; i < sectionHeaderCount; i++)
|
||||
{
|
||||
stream.Seek(sectionHeaderOffset + i * sectionHeaderSize, SeekOrigin.Begin);
|
||||
var shName = reader.ReadUInt32();
|
||||
var shType = reader.ReadUInt32();
|
||||
|
||||
if (shType == 2) // SHT_SYMTAB
|
||||
{
|
||||
stream.Seek(sectionHeaderOffset + i * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
|
||||
symtabOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
symtabSize = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
stream.Seek(is64Bit ? 8 : 4, SeekOrigin.Current);
|
||||
symtabEntrySize = (int)(is64Bit ? reader.ReadInt64() : reader.ReadInt32());
|
||||
}
|
||||
else if (shType == 3) // SHT_STRTAB
|
||||
{
|
||||
stream.Seek(sectionHeaderOffset + i * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
|
||||
strtabOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
strtabSize = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
}
|
||||
}
|
||||
|
||||
if (symtabOffset == 0 || strtabOffset == 0)
|
||||
{
|
||||
_logger.LogDebug("No symbol table found in ELF binary");
|
||||
return symbols;
|
||||
}
|
||||
|
||||
// Read string table
|
||||
stream.Seek(strtabOffset, SeekOrigin.Begin);
|
||||
var strtab = reader.ReadBytes((int)strtabSize);
|
||||
|
||||
// Read symbols
|
||||
var symCount = (int)(symtabSize / symtabEntrySize);
|
||||
for (int i = 0; i < symCount; i++)
|
||||
{
|
||||
stream.Seek(symtabOffset + i * symtabEntrySize, SeekOrigin.Begin);
|
||||
|
||||
string name;
|
||||
ulong address;
|
||||
ulong size;
|
||||
byte info;
|
||||
byte visibility;
|
||||
|
||||
if (is64Bit)
|
||||
{
|
||||
var nameIdx = reader.ReadUInt32();
|
||||
info = reader.ReadByte();
|
||||
visibility = reader.ReadByte();
|
||||
reader.ReadUInt16(); // section index
|
||||
address = reader.ReadUInt64();
|
||||
size = reader.ReadUInt64();
|
||||
name = ReadNullTerminatedString(strtab, (int)nameIdx);
|
||||
}
|
||||
else
|
||||
{
|
||||
var nameIdx = reader.ReadUInt32();
|
||||
address = reader.ReadUInt32();
|
||||
size = reader.ReadUInt32();
|
||||
info = reader.ReadByte();
|
||||
visibility = reader.ReadByte();
|
||||
reader.ReadUInt16(); // section index
|
||||
name = ReadNullTerminatedString(strtab, (int)nameIdx);
|
||||
}
|
||||
|
||||
var type = info & 0xF;
|
||||
var bind = info >> 4;
|
||||
|
||||
// Only include function symbols (type 2 = STT_FUNC)
|
||||
if (type == 2 && !string.IsNullOrEmpty(name))
|
||||
{
|
||||
symbols.Add(new BinarySymbol
|
||||
{
|
||||
Name = name,
|
||||
Address = address,
|
||||
Size = size,
|
||||
IsGlobal = bind == 1, // STB_GLOBAL
|
||||
IsExported = bind == 1 || bind == 2 // GLOBAL or WEAK
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
_logger.LogDebug("Extracted {Count} function symbols from ELF", symbols.Count);
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private async Task<List<BinarySymbol>> ExtractPeSymbolsAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var symbols = new List<BinarySymbol>();
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
// Read DOS header
|
||||
stream.Seek(0x3C, SeekOrigin.Begin);
|
||||
var peOffset = reader.ReadInt32();
|
||||
|
||||
// Verify PE signature
|
||||
stream.Seek(peOffset, SeekOrigin.Begin);
|
||||
var signature = reader.ReadUInt32();
|
||||
if (signature != 0x00004550) // "PE\0\0"
|
||||
{
|
||||
throw new NotSupportedException("Invalid PE signature");
|
||||
}
|
||||
|
||||
// Read COFF header
|
||||
var machine = reader.ReadUInt16();
|
||||
var numberOfSections = reader.ReadUInt16();
|
||||
reader.ReadUInt32(); // timestamp
|
||||
var symbolTableOffset = reader.ReadUInt32();
|
||||
var numberOfSymbols = reader.ReadUInt32();
|
||||
var optionalHeaderSize = reader.ReadUInt16();
|
||||
reader.ReadUInt16(); // characteristics
|
||||
|
||||
var is64Bit = machine == 0x8664; // AMD64
|
||||
|
||||
// Read optional header to get export directory
|
||||
if (optionalHeaderSize > 0)
|
||||
{
|
||||
var optionalHeaderStart = stream.Position;
|
||||
reader.ReadUInt16(); // magic
|
||||
stream.Seek(optionalHeaderStart + (is64Bit ? 112 : 96), SeekOrigin.Begin);
|
||||
var exportRva = reader.ReadUInt32();
|
||||
var exportSize = reader.ReadUInt32();
|
||||
|
||||
if (exportRva > 0)
|
||||
{
|
||||
// Would need to convert RVA to file offset and parse export directory
|
||||
// For now, just log that exports exist
|
||||
_logger.LogDebug("PE has export directory at RVA 0x{Rva:X}", exportRva);
|
||||
}
|
||||
}
|
||||
|
||||
// Read COFF symbol table if present
|
||||
if (symbolTableOffset > 0 && numberOfSymbols > 0)
|
||||
{
|
||||
stream.Seek(symbolTableOffset, SeekOrigin.Begin);
|
||||
|
||||
for (uint i = 0; i < numberOfSymbols; i++)
|
||||
{
|
||||
var nameBytes = reader.ReadBytes(8);
|
||||
var value = reader.ReadUInt32();
|
||||
var section = reader.ReadInt16();
|
||||
var type = reader.ReadUInt16();
|
||||
var storageClass = reader.ReadByte();
|
||||
var auxCount = reader.ReadByte();
|
||||
|
||||
// Skip auxiliary symbols
|
||||
i += auxCount;
|
||||
if (auxCount > 0)
|
||||
{
|
||||
reader.ReadBytes(auxCount * 18);
|
||||
}
|
||||
|
||||
// Check if it's a function (type 0x20 = DT_FUNCTION)
|
||||
if ((type & 0xF0) == 0x20 && section > 0)
|
||||
{
|
||||
string name;
|
||||
if (nameBytes[0] == 0 && nameBytes[1] == 0 && nameBytes[2] == 0 && nameBytes[3] == 0)
|
||||
{
|
||||
// Long name - offset into string table
|
||||
var offset = BitConverter.ToUInt32(nameBytes, 4);
|
||||
// Would need to read from string table
|
||||
name = $"func_{value:X}";
|
||||
}
|
||||
else
|
||||
{
|
||||
name = System.Text.Encoding.ASCII.GetString(nameBytes).TrimEnd('\0');
|
||||
}
|
||||
|
||||
symbols.Add(new BinarySymbol
|
||||
{
|
||||
Name = name,
|
||||
Address = value,
|
||||
Size = 0, // PE doesn't store function size in symbol table
|
||||
IsGlobal = storageClass == 2, // IMAGE_SYM_CLASS_EXTERNAL
|
||||
IsExported = false // Would need to check export directory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
_logger.LogDebug("Extracted {Count} function symbols from PE", symbols.Count);
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private async Task<List<BinarySymbol>> ExtractMachOSymbolsAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var symbols = new List<BinarySymbol>();
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
var magic = reader.ReadUInt32();
|
||||
var is64Bit = magic is 0xFEEDFACF or 0xCFFAEDFE;
|
||||
var isSwapped = magic is 0xCEFAEDFE or 0xCFFAEDFE;
|
||||
|
||||
// Skip header
|
||||
stream.Seek(is64Bit ? 32 : 28, SeekOrigin.Begin);
|
||||
|
||||
// Read load commands
|
||||
stream.Seek(is64Bit ? 16 : 12, SeekOrigin.Begin);
|
||||
var ncmds = reader.ReadUInt32();
|
||||
var sizeofcmds = reader.ReadUInt32();
|
||||
|
||||
stream.Seek(is64Bit ? 32 : 28, SeekOrigin.Begin);
|
||||
|
||||
long symtabOffset = 0, strtabOffset = 0;
|
||||
uint nsyms = 0, strtabSize = 0;
|
||||
|
||||
for (uint i = 0; i < ncmds; i++)
|
||||
{
|
||||
var cmdStart = stream.Position;
|
||||
var cmd = reader.ReadUInt32();
|
||||
var cmdsize = reader.ReadUInt32();
|
||||
|
||||
if (cmd == 2) // LC_SYMTAB
|
||||
{
|
||||
symtabOffset = reader.ReadUInt32();
|
||||
nsyms = reader.ReadUInt32();
|
||||
strtabOffset = reader.ReadUInt32();
|
||||
strtabSize = reader.ReadUInt32();
|
||||
break;
|
||||
}
|
||||
|
||||
stream.Seek(cmdStart + cmdsize, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
if (symtabOffset == 0)
|
||||
{
|
||||
_logger.LogDebug("No symbol table found in Mach-O binary");
|
||||
return symbols;
|
||||
}
|
||||
|
||||
// Read string table
|
||||
stream.Seek(strtabOffset, SeekOrigin.Begin);
|
||||
var strtab = reader.ReadBytes((int)strtabSize);
|
||||
|
||||
// Read symbols
|
||||
var nlistSize = is64Bit ? 16 : 12;
|
||||
for (uint i = 0; i < nsyms; i++)
|
||||
{
|
||||
stream.Seek(symtabOffset + i * nlistSize, SeekOrigin.Begin);
|
||||
|
||||
var nameIdx = reader.ReadUInt32();
|
||||
var type = reader.ReadByte();
|
||||
var sect = reader.ReadByte();
|
||||
var desc = reader.ReadInt16();
|
||||
var value = is64Bit ? reader.ReadUInt64() : reader.ReadUInt32();
|
||||
|
||||
// Check if it's an external function (N_EXT | N_SECT)
|
||||
if ((type & 0x0E) == 0x0E && sect > 0)
|
||||
{
|
||||
var name = ReadNullTerminatedString(strtab, (int)nameIdx);
|
||||
if (!string.IsNullOrEmpty(name) && !name.StartsWith("_OBJC_"))
|
||||
{
|
||||
symbols.Add(new BinarySymbol
|
||||
{
|
||||
Name = name.TrimStart('_'),
|
||||
Address = value,
|
||||
Size = 0,
|
||||
IsGlobal = (type & 0x01) != 0,
|
||||
IsExported = (type & 0x01) != 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
_logger.LogDebug("Extracted {Count} function symbols from Mach-O", symbols.Count);
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private async Task<List<BinaryRelocation>> ExtractElfRelocationsAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var relocations = new List<BinaryRelocation>();
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
// Read ELF header
|
||||
var ident = reader.ReadBytes(16);
|
||||
var is64Bit = ident[4] == 2;
|
||||
|
||||
// Read section header info
|
||||
stream.Seek(is64Bit ? 40 : 32, SeekOrigin.Begin);
|
||||
var sectionHeaderOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
stream.Seek(is64Bit ? 58 : 46, SeekOrigin.Begin);
|
||||
var sectionHeaderSize = reader.ReadUInt16();
|
||||
var sectionHeaderCount = reader.ReadUInt16();
|
||||
var strTabIndex = reader.ReadUInt16();
|
||||
|
||||
// Read section name string table
|
||||
stream.Seek(sectionHeaderOffset + strTabIndex * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
|
||||
var shStrTabOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
var shStrTabSize = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
stream.Seek(shStrTabOffset, SeekOrigin.Begin);
|
||||
var shStrTab = reader.ReadBytes((int)shStrTabSize);
|
||||
|
||||
// Find symbol and string tables for resolving names
|
||||
long symtabOffset = 0, strtabOffset = 0;
|
||||
long symtabSize = 0;
|
||||
int symtabEntrySize = is64Bit ? 24 : 16;
|
||||
|
||||
// Find .dynsym and .dynstr for dynamic relocations
|
||||
long dynsymOffset = 0, dynstrOffset = 0;
|
||||
long dynsymSize = 0;
|
||||
|
||||
for (int i = 0; i < sectionHeaderCount; i++)
|
||||
{
|
||||
stream.Seek(sectionHeaderOffset + i * sectionHeaderSize, SeekOrigin.Begin);
|
||||
var shNameIdx = reader.ReadUInt32();
|
||||
var shType = reader.ReadUInt32();
|
||||
|
||||
var sectionName = ReadNullTerminatedString(shStrTab, (int)shNameIdx);
|
||||
|
||||
stream.Seek(sectionHeaderOffset + i * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
|
||||
var shOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
var shSize = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
if (shType == 11) // SHT_DYNSYM
|
||||
{
|
||||
dynsymOffset = shOffset;
|
||||
dynsymSize = shSize;
|
||||
}
|
||||
else if (sectionName == ".dynstr")
|
||||
{
|
||||
dynstrOffset = shOffset;
|
||||
}
|
||||
else if (shType == 4 || shType == 9) // SHT_RELA or SHT_REL
|
||||
{
|
||||
// Process relocation section
|
||||
var isRela = shType == 4;
|
||||
var entrySize = is64Bit
|
||||
? (isRela ? 24 : 16)
|
||||
: (isRela ? 12 : 8);
|
||||
|
||||
var relCount = (int)(shSize / entrySize);
|
||||
|
||||
for (int r = 0; r < relCount; r++)
|
||||
{
|
||||
stream.Seek(shOffset + r * entrySize, SeekOrigin.Begin);
|
||||
|
||||
ulong relocOffset;
|
||||
ulong relocInfo;
|
||||
|
||||
if (is64Bit)
|
||||
{
|
||||
relocOffset = reader.ReadUInt64();
|
||||
relocInfo = reader.ReadUInt64();
|
||||
}
|
||||
else
|
||||
{
|
||||
relocOffset = reader.ReadUInt32();
|
||||
relocInfo = reader.ReadUInt32();
|
||||
}
|
||||
|
||||
// Extract symbol index
|
||||
var symIndex = is64Bit
|
||||
? (uint)(relocInfo >> 32)
|
||||
: relocInfo >> 8;
|
||||
|
||||
if (symIndex > 0 && dynsymOffset > 0)
|
||||
{
|
||||
relocations.Add(new BinaryRelocation
|
||||
{
|
||||
Address = relocOffset,
|
||||
SymbolIndex = (int)symIndex,
|
||||
SourceSymbol = "", // Will be resolved later
|
||||
TargetSymbol = "", // Will be resolved later
|
||||
IsExternal = true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve symbol names from dynsym
|
||||
if (dynsymOffset > 0 && dynstrOffset > 0 && relocations.Count > 0)
|
||||
{
|
||||
// Read dynstr
|
||||
stream.Seek(dynstrOffset, SeekOrigin.Begin);
|
||||
// Estimate a reasonable size
|
||||
var dynstrSize = Math.Min(0x10000, stream.Length - dynstrOffset);
|
||||
var dynstr = reader.ReadBytes((int)dynstrSize);
|
||||
|
||||
var symCount = (int)(dynsymSize / symtabEntrySize);
|
||||
|
||||
foreach (var reloc in relocations)
|
||||
{
|
||||
if (reloc.SymbolIndex < symCount)
|
||||
{
|
||||
stream.Seek(dynsymOffset + reloc.SymbolIndex * symtabEntrySize, SeekOrigin.Begin);
|
||||
|
||||
uint nameIdx;
|
||||
if (is64Bit)
|
||||
{
|
||||
nameIdx = reader.ReadUInt32();
|
||||
}
|
||||
else
|
||||
{
|
||||
nameIdx = reader.ReadUInt32();
|
||||
}
|
||||
|
||||
var symbolName = ReadNullTerminatedString(dynstr, (int)nameIdx);
|
||||
reloc.TargetSymbol = symbolName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
_logger.LogDebug("Extracted {Count} relocations from ELF", relocations.Count);
|
||||
return relocations;
|
||||
}
|
||||
|
||||
private async Task<List<BinaryRelocation>> ExtractPeRelocationsAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var relocations = new List<BinaryRelocation>();
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
// Read DOS header
|
||||
stream.Seek(0x3C, SeekOrigin.Begin);
|
||||
var peOffset = reader.ReadInt32();
|
||||
|
||||
// Verify PE signature
|
||||
stream.Seek(peOffset, SeekOrigin.Begin);
|
||||
var signature = reader.ReadUInt32();
|
||||
if (signature != 0x00004550)
|
||||
{
|
||||
return relocations;
|
||||
}
|
||||
|
||||
// Read COFF header
|
||||
var machine = reader.ReadUInt16();
|
||||
var numberOfSections = reader.ReadUInt16();
|
||||
stream.Seek(12, SeekOrigin.Current); // Skip to optional header
|
||||
var sizeOfOptionalHeader = reader.ReadUInt16();
|
||||
reader.ReadUInt16(); // characteristics
|
||||
|
||||
if (sizeOfOptionalHeader == 0)
|
||||
{
|
||||
return relocations;
|
||||
}
|
||||
|
||||
var optionalHeaderStart = stream.Position;
|
||||
var magic = reader.ReadUInt16();
|
||||
var is64Bit = magic == 0x20b; // PE32+
|
||||
|
||||
// Skip to data directories
|
||||
stream.Seek(optionalHeaderStart + (is64Bit ? 112 : 96), SeekOrigin.Begin);
|
||||
|
||||
// Read import table RVA and size (directory entry 1)
|
||||
stream.Seek(8, SeekOrigin.Current); // Skip export table
|
||||
var importTableRva = reader.ReadUInt32();
|
||||
var importTableSize = reader.ReadUInt32();
|
||||
|
||||
if (importTableRva == 0)
|
||||
{
|
||||
return relocations;
|
||||
}
|
||||
|
||||
// Read section headers to find import table location
|
||||
var sectionHeadersStart = optionalHeaderStart + sizeOfOptionalHeader;
|
||||
var importTableOffset = RvaToFileOffset(stream, reader, sectionHeadersStart, numberOfSections, importTableRva);
|
||||
|
||||
if (importTableOffset == 0)
|
||||
{
|
||||
return relocations;
|
||||
}
|
||||
|
||||
// Parse import directory
|
||||
stream.Seek(importTableOffset, SeekOrigin.Begin);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var importLookupTableRva = reader.ReadUInt32();
|
||||
reader.ReadUInt32(); // timestamp
|
||||
reader.ReadUInt32(); // forwarder chain
|
||||
var nameRva = reader.ReadUInt32();
|
||||
reader.ReadUInt32(); // import address table RVA
|
||||
|
||||
if (importLookupTableRva == 0 && nameRva == 0)
|
||||
{
|
||||
break; // End of import directory
|
||||
}
|
||||
|
||||
// Read DLL name
|
||||
var nameOffset = RvaToFileOffset(stream, reader, sectionHeadersStart, numberOfSections, nameRva);
|
||||
var currentPos = stream.Position;
|
||||
stream.Seek(nameOffset, SeekOrigin.Begin);
|
||||
var dllName = ReadCString(reader);
|
||||
stream.Seek(currentPos, SeekOrigin.Begin);
|
||||
|
||||
// Parse import lookup table
|
||||
var lookupOffset = RvaToFileOffset(stream, reader, sectionHeadersStart, numberOfSections, importLookupTableRva);
|
||||
if (lookupOffset > 0)
|
||||
{
|
||||
var lookupPos = stream.Position;
|
||||
stream.Seek(lookupOffset, SeekOrigin.Begin);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var entry = is64Bit ? reader.ReadUInt64() : reader.ReadUInt32();
|
||||
if (entry == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var isOrdinal = is64Bit
|
||||
? (entry & 0x8000000000000000) != 0
|
||||
: (entry & 0x80000000) != 0;
|
||||
|
||||
if (!isOrdinal)
|
||||
{
|
||||
var hintNameRva = (uint)(entry & 0x7FFFFFFF);
|
||||
var hintNameOffset = RvaToFileOffset(stream, reader, sectionHeadersStart, numberOfSections, hintNameRva);
|
||||
|
||||
if (hintNameOffset > 0)
|
||||
{
|
||||
var entryPos = stream.Position;
|
||||
stream.Seek(hintNameOffset + 2, SeekOrigin.Begin); // Skip hint
|
||||
var funcName = ReadCString(reader);
|
||||
stream.Seek(entryPos, SeekOrigin.Begin);
|
||||
|
||||
relocations.Add(new BinaryRelocation
|
||||
{
|
||||
Address = 0,
|
||||
SymbolIndex = 0,
|
||||
SourceSymbol = dllName,
|
||||
TargetSymbol = funcName,
|
||||
IsExternal = true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream.Seek(lookupPos, SeekOrigin.Begin);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
_logger.LogDebug("Extracted {Count} imports from PE", relocations.Count);
|
||||
return relocations;
|
||||
}
|
||||
|
||||
private long RvaToFileOffset(
|
||||
Stream stream,
|
||||
BinaryReader reader,
|
||||
long sectionHeadersStart,
|
||||
int numberOfSections,
|
||||
uint rva)
|
||||
{
|
||||
var currentPos = stream.Position;
|
||||
|
||||
for (int i = 0; i < numberOfSections; i++)
|
||||
{
|
||||
stream.Seek(sectionHeadersStart + i * 40 + 12, SeekOrigin.Begin);
|
||||
var virtualAddress = reader.ReadUInt32();
|
||||
var sizeOfRawData = reader.ReadUInt32();
|
||||
var pointerToRawData = reader.ReadUInt32();
|
||||
|
||||
if (rva >= virtualAddress && rva < virtualAddress + sizeOfRawData)
|
||||
{
|
||||
stream.Seek(currentPos, SeekOrigin.Begin);
|
||||
return pointerToRawData + (rva - virtualAddress);
|
||||
}
|
||||
}
|
||||
|
||||
stream.Seek(currentPos, SeekOrigin.Begin);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string ReadCString(BinaryReader reader)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
byte b;
|
||||
while ((b = reader.ReadByte()) != 0)
|
||||
{
|
||||
bytes.Add(b);
|
||||
}
|
||||
return System.Text.Encoding.ASCII.GetString(bytes.ToArray());
|
||||
}
|
||||
|
||||
private async Task<List<BinaryRelocation>> ExtractMachORelocationsAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var relocations = new List<BinaryRelocation>();
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
var magic = reader.ReadUInt32();
|
||||
var is64Bit = magic == 0xFEEDFACF || magic == 0xCFFAEDFE;
|
||||
var needsSwap = magic == 0xCEFAEDFE || magic == 0xCFFAEDFE;
|
||||
|
||||
// Read header
|
||||
reader.ReadUInt32(); // cputype
|
||||
reader.ReadUInt32(); // cpusubtype
|
||||
reader.ReadUInt32(); // filetype
|
||||
var ncmds = reader.ReadUInt32();
|
||||
reader.ReadUInt32(); // sizeofcmds
|
||||
reader.ReadUInt32(); // flags
|
||||
if (is64Bit)
|
||||
{
|
||||
reader.ReadUInt32(); // reserved
|
||||
}
|
||||
|
||||
// Parse load commands looking for LC_SYMTAB and LC_DYSYMTAB
|
||||
for (int i = 0; i < ncmds; i++)
|
||||
{
|
||||
var cmdStart = stream.Position;
|
||||
var cmd = reader.ReadUInt32();
|
||||
var cmdsize = reader.ReadUInt32();
|
||||
|
||||
if (cmd == 0x0B) // LC_DYSYMTAB
|
||||
{
|
||||
// Skip to external relocation entries
|
||||
stream.Seek(cmdStart + 60, SeekOrigin.Begin);
|
||||
var extreloff = reader.ReadUInt32();
|
||||
var nextrel = reader.ReadUInt32();
|
||||
|
||||
// Parse external relocations
|
||||
for (int r = 0; r < nextrel; r++)
|
||||
{
|
||||
stream.Seek(extreloff + r * 8, SeekOrigin.Begin);
|
||||
var address = reader.ReadUInt32();
|
||||
var info = reader.ReadUInt32();
|
||||
|
||||
var symbolIndex = info & 0x00FFFFFF;
|
||||
|
||||
relocations.Add(new BinaryRelocation
|
||||
{
|
||||
Address = address,
|
||||
SymbolIndex = (int)symbolIndex,
|
||||
SourceSymbol = "",
|
||||
TargetSymbol = "",
|
||||
IsExternal = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stream.Seek(cmdStart + cmdsize, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
_logger.LogDebug("Extracted {Count} relocations from Mach-O", relocations.Count);
|
||||
return relocations;
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildSnapshot(
|
||||
string scanId,
|
||||
string binaryPath,
|
||||
List<BinarySymbol> symbols,
|
||||
List<BinaryRelocation> relocations)
|
||||
{
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
var binaryName = Path.GetFileName(binaryPath);
|
||||
|
||||
foreach (var symbol in symbols)
|
||||
{
|
||||
var nodeId = $"native:{binaryName}/{symbol.Name}";
|
||||
var entrypointType = _entrypointClassifier.Classify(symbol);
|
||||
|
||||
var node = new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: symbol.Name,
|
||||
File: binaryPath,
|
||||
Line: 0,
|
||||
Package: binaryName,
|
||||
Visibility: symbol.IsExported ? Visibility.Public : Visibility.Private,
|
||||
IsEntrypoint: entrypointType.HasValue,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: false,
|
||||
SinkCategory: null);
|
||||
|
||||
nodesById.TryAdd(node.NodeId, node);
|
||||
}
|
||||
|
||||
// Add edges from relocations
|
||||
foreach (var reloc in relocations)
|
||||
{
|
||||
var sourceId = $"native:{binaryName}/{reloc.SourceSymbol}";
|
||||
var targetId = reloc.IsExternal
|
||||
? $"native:external/{reloc.TargetSymbol}"
|
||||
: $"native:{binaryName}/{reloc.TargetSymbol}";
|
||||
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: sourceId,
|
||||
TargetId: targetId,
|
||||
CallKind: CallKind.Direct,
|
||||
CallSite: $"0x{reloc.Address:X}"));
|
||||
}
|
||||
|
||||
var nodes = nodesById.Values
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypointIds = nodes
|
||||
.Where(n => n.IsEntrypoint)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: nodes,
|
||||
Edges: orderedEdges,
|
||||
EntrypointIds: entrypointIds,
|
||||
SinkIds: []);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Binary call graph extracted: {Nodes} nodes, {Edges} edges, {Entrypoints} entrypoints",
|
||||
nodes.Length, orderedEdges.Length, entrypointIds.Length);
|
||||
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private static string ReadNullTerminatedString(byte[] buffer, int offset)
|
||||
{
|
||||
if (offset < 0 || offset >= buffer.Length)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var end = offset;
|
||||
while (end < buffer.Length && buffer[end] != 0)
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetString(buffer, offset, end - offset);
|
||||
}
|
||||
}
|
||||
|
||||
internal enum BinaryFormat
|
||||
{
|
||||
Unknown,
|
||||
Elf,
|
||||
Pe,
|
||||
MachO
|
||||
}
|
||||
|
||||
internal sealed class BinarySymbol
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public ulong Address { get; init; }
|
||||
public ulong Size { get; init; }
|
||||
public bool IsGlobal { get; init; }
|
||||
public bool IsExported { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class BinaryRelocation
|
||||
{
|
||||
public required string SourceSymbol { get; init; }
|
||||
public string TargetSymbol { get; set; } = "";
|
||||
public ulong Address { get; init; }
|
||||
public bool IsExternal { get; init; }
|
||||
public int SymbolIndex { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryEntrypointClassifier.cs
|
||||
// Sprint: SPRINT_3610_0006_0001_binary_callgraph
|
||||
// Description: Classifies binary symbols as entrypoints based on naming patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Binary;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies binary symbols as entrypoints based on common naming conventions.
|
||||
/// </summary>
|
||||
internal sealed class BinaryEntrypointClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard entry point symbol names.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> EntrySymbols = new(StringComparer.Ordinal)
|
||||
{
|
||||
"main",
|
||||
"_main",
|
||||
"__main",
|
||||
"_start",
|
||||
"__start",
|
||||
"WinMain",
|
||||
"wWinMain",
|
||||
"WinMainCRTStartup",
|
||||
"DllMain",
|
||||
"DllEntryPoint",
|
||||
"_DllMainCRTStartup"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// CGI/FCGI handler symbols.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> CgiSymbols = new(StringComparer.Ordinal)
|
||||
{
|
||||
"cgi_main",
|
||||
"fcgi_main",
|
||||
"FCGI_Accept"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Signal handler naming patterns.
|
||||
/// </summary>
|
||||
private static readonly string[] SignalHandlerPrefixes =
|
||||
{
|
||||
"sighandler_",
|
||||
"signal_handler_",
|
||||
"sig_",
|
||||
"handle_sig"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Plugin/module initialization symbols.
|
||||
/// </summary>
|
||||
private static readonly string[] ModuleInitPatterns =
|
||||
{
|
||||
"_init",
|
||||
"_fini",
|
||||
"module_init",
|
||||
"plugin_init",
|
||||
"init_module"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a symbol as an entrypoint based on its name.
|
||||
/// </summary>
|
||||
/// <param name="symbolName">The symbol name to classify.</param>
|
||||
/// <returns>Entrypoint type if matched; otherwise, null.</returns>
|
||||
public EntrypointType? Classify(string symbolName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(symbolName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for standard entry points
|
||||
if (EntrySymbols.Contains(symbolName))
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
|
||||
// Check for CGI handlers
|
||||
if (CgiSymbols.Contains(symbolName))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Check for signal handlers
|
||||
foreach (var prefix in SignalHandlerPrefixes)
|
||||
{
|
||||
if (symbolName.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return EntrypointType.EventHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for module initialization
|
||||
foreach (var pattern in ModuleInitPatterns)
|
||||
{
|
||||
if (symbolName.Contains(pattern, StringComparison.Ordinal))
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for exported API functions (common patterns)
|
||||
if (symbolName.StartsWith("API_", StringComparison.Ordinal) ||
|
||||
symbolName.StartsWith("api_", StringComparison.Ordinal))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a binary symbol record.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The symbol to classify.</param>
|
||||
/// <returns>Entrypoint type if matched; otherwise, null.</returns>
|
||||
public EntrypointType? Classify(BinarySymbol symbol)
|
||||
{
|
||||
if (symbol is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Classify based on symbol name
|
||||
var result = Classify(symbol.Name);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for exported symbols that could be entrypoints
|
||||
if (symbol.IsExported)
|
||||
{
|
||||
// Heuristic: public exported functions with common handler patterns
|
||||
if (symbol.Name.Contains("handler", StringComparison.OrdinalIgnoreCase) ||
|
||||
symbol.Name.Contains("callback", StringComparison.OrdinalIgnoreCase) ||
|
||||
symbol.Name.Contains("process", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.EventHandler;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DwarfDebugReader.cs
|
||||
// Sprint: SPRINT_3610_0006_0001_binary_callgraph
|
||||
// Description: Reads DWARF debug information from ELF binaries.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Binary;
|
||||
|
||||
/// <summary>
|
||||
/// Reads DWARF debug information from ELF binaries to extract source-level
|
||||
/// function names, file paths, and line number mappings.
|
||||
/// </summary>
|
||||
public sealed class DwarfDebugReader
|
||||
{
|
||||
private readonly ILogger<DwarfDebugReader> _logger;
|
||||
|
||||
// DWARF section names
|
||||
private const string DebugInfoSection = ".debug_info";
|
||||
private const string DebugAbbrSection = ".debug_abbrev";
|
||||
private const string DebugStrSection = ".debug_str";
|
||||
private const string DebugLineSection = ".debug_line";
|
||||
private const string DebugRangesSection = ".debug_ranges";
|
||||
|
||||
// DWARF tags
|
||||
private const ushort DW_TAG_compile_unit = 0x11;
|
||||
private const ushort DW_TAG_subprogram = 0x2e;
|
||||
private const ushort DW_TAG_inlined_subroutine = 0x1d;
|
||||
private const ushort DW_TAG_formal_parameter = 0x05;
|
||||
|
||||
// DWARF attributes
|
||||
private const ushort DW_AT_name = 0x03;
|
||||
private const ushort DW_AT_low_pc = 0x11;
|
||||
private const ushort DW_AT_high_pc = 0x12;
|
||||
private const ushort DW_AT_decl_file = 0x3a;
|
||||
private const ushort DW_AT_decl_line = 0x3b;
|
||||
private const ushort DW_AT_linkage_name = 0x6e;
|
||||
private const ushort DW_AT_external = 0x3f;
|
||||
|
||||
public DwarfDebugReader(ILogger<DwarfDebugReader> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads DWARF debug information from an ELF binary.
|
||||
/// </summary>
|
||||
/// <param name="elfPath">Path to the ELF binary.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Debug information including functions and source mappings.</returns>
|
||||
public async Task<DwarfDebugInfo> ReadAsync(string elfPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(elfPath);
|
||||
|
||||
if (!File.Exists(elfPath))
|
||||
{
|
||||
throw new FileNotFoundException($"ELF binary not found: {elfPath}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Reading DWARF debug info from {Path}", elfPath);
|
||||
|
||||
var functions = new List<DwarfFunction>();
|
||||
var sourceFiles = new List<string>();
|
||||
|
||||
await using var stream = File.OpenRead(elfPath);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
// Read ELF header to find section headers
|
||||
var sections = await ReadElfSectionsAsync(reader, cancellationToken);
|
||||
|
||||
// Find DWARF sections
|
||||
var debugInfoSection = sections.FirstOrDefault(s => s.Name == DebugInfoSection);
|
||||
var debugAbbrSection = sections.FirstOrDefault(s => s.Name == DebugAbbrSection);
|
||||
var debugStrSection = sections.FirstOrDefault(s => s.Name == DebugStrSection);
|
||||
var debugLineSection = sections.FirstOrDefault(s => s.Name == DebugLineSection);
|
||||
|
||||
if (debugInfoSection is null)
|
||||
{
|
||||
_logger.LogDebug("No .debug_info section found - binary has no DWARF info");
|
||||
return new DwarfDebugInfo { Functions = [], SourceFiles = [] };
|
||||
}
|
||||
|
||||
// Read string table
|
||||
var stringTable = debugStrSection is not null
|
||||
? await ReadSectionDataAsync(reader, debugStrSection, cancellationToken)
|
||||
: [];
|
||||
|
||||
// Read abbreviation table
|
||||
var abbreviations = debugAbbrSection is not null
|
||||
? await ReadAbbreviationsAsync(reader, debugAbbrSection, cancellationToken)
|
||||
: new Dictionary<ulong, DwarfAbbreviation>();
|
||||
|
||||
// Read debug info entries
|
||||
var debugInfoData = await ReadSectionDataAsync(reader, debugInfoSection, cancellationToken);
|
||||
ParseDebugInfo(debugInfoData, abbreviations, stringTable, functions, sourceFiles);
|
||||
|
||||
_logger.LogDebug("Found {Count} functions with DWARF info", functions.Count);
|
||||
|
||||
return new DwarfDebugInfo
|
||||
{
|
||||
Functions = functions,
|
||||
SourceFiles = sourceFiles
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<ElfSection>> ReadElfSectionsAsync(BinaryReader reader, CancellationToken ct)
|
||||
{
|
||||
var sections = new List<ElfSection>();
|
||||
|
||||
reader.BaseStream.Seek(0, SeekOrigin.Begin);
|
||||
var ident = reader.ReadBytes(16);
|
||||
|
||||
if (ident[0] != 0x7F || ident[1] != 'E' || ident[2] != 'L' || ident[3] != 'F')
|
||||
{
|
||||
throw new InvalidOperationException("Not a valid ELF file");
|
||||
}
|
||||
|
||||
var is64Bit = ident[4] == 2;
|
||||
|
||||
// Read section header info from ELF header
|
||||
reader.BaseStream.Seek(is64Bit ? 40 : 32, SeekOrigin.Begin);
|
||||
var sectionHeaderOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
reader.BaseStream.Seek(is64Bit ? 58 : 46, SeekOrigin.Begin);
|
||||
var sectionHeaderSize = reader.ReadUInt16();
|
||||
var sectionHeaderCount = reader.ReadUInt16();
|
||||
var strTabIndex = reader.ReadUInt16();
|
||||
|
||||
// Read section name string table
|
||||
reader.BaseStream.Seek(sectionHeaderOffset + strTabIndex * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
|
||||
var strTabOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
var strTabSize = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
reader.BaseStream.Seek(strTabOffset, SeekOrigin.Begin);
|
||||
var strTab = reader.ReadBytes((int)strTabSize);
|
||||
|
||||
// Read all section headers
|
||||
for (int i = 0; i < sectionHeaderCount; i++)
|
||||
{
|
||||
reader.BaseStream.Seek(sectionHeaderOffset + i * sectionHeaderSize, SeekOrigin.Begin);
|
||||
|
||||
var nameIndex = reader.ReadUInt32();
|
||||
var type = reader.ReadUInt32();
|
||||
|
||||
reader.BaseStream.Seek(sectionHeaderOffset + i * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
|
||||
var offset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
var size = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
var name = ReadNullTerminatedString(strTab, (int)nameIndex);
|
||||
|
||||
sections.Add(new ElfSection
|
||||
{
|
||||
Name = name,
|
||||
Type = type,
|
||||
Offset = offset,
|
||||
Size = size
|
||||
});
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
return sections;
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadSectionDataAsync(BinaryReader reader, ElfSection section, CancellationToken ct)
|
||||
{
|
||||
reader.BaseStream.Seek(section.Offset, SeekOrigin.Begin);
|
||||
var data = reader.ReadBytes((int)section.Size);
|
||||
await Task.CompletedTask;
|
||||
return data;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<ulong, DwarfAbbreviation>> ReadAbbreviationsAsync(
|
||||
BinaryReader reader,
|
||||
ElfSection section,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var abbreviations = new Dictionary<ulong, DwarfAbbreviation>();
|
||||
var data = await ReadSectionDataAsync(reader, section, ct);
|
||||
|
||||
var offset = 0;
|
||||
while (offset < data.Length)
|
||||
{
|
||||
var code = ReadULEB128(data, ref offset);
|
||||
if (code == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tag = (ushort)ReadULEB128(data, ref offset);
|
||||
var hasChildren = data[offset++] != 0;
|
||||
|
||||
var attributes = new List<(ushort Name, ushort Form)>();
|
||||
while (true)
|
||||
{
|
||||
var attrName = (ushort)ReadULEB128(data, ref offset);
|
||||
var attrForm = (ushort)ReadULEB128(data, ref offset);
|
||||
if (attrName == 0 && attrForm == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
attributes.Add((attrName, attrForm));
|
||||
}
|
||||
|
||||
abbreviations[code] = new DwarfAbbreviation
|
||||
{
|
||||
Code = code,
|
||||
Tag = tag,
|
||||
HasChildren = hasChildren,
|
||||
Attributes = attributes
|
||||
};
|
||||
}
|
||||
|
||||
return abbreviations;
|
||||
}
|
||||
|
||||
private void ParseDebugInfo(
|
||||
byte[] data,
|
||||
Dictionary<ulong, DwarfAbbreviation> abbreviations,
|
||||
byte[] stringTable,
|
||||
List<DwarfFunction> functions,
|
||||
List<string> sourceFiles)
|
||||
{
|
||||
var offset = 0;
|
||||
|
||||
while (offset < data.Length)
|
||||
{
|
||||
// Read compilation unit header
|
||||
var unitLength = BitConverter.ToUInt32(data, offset);
|
||||
if (unitLength == 0xFFFFFFFF)
|
||||
{
|
||||
// 64-bit DWARF - skip for now
|
||||
break;
|
||||
}
|
||||
|
||||
var unitEnd = offset + 4 + (int)unitLength;
|
||||
offset += 4;
|
||||
|
||||
var version = BitConverter.ToUInt16(data, offset);
|
||||
offset += 2;
|
||||
|
||||
var abbrevOffset = BitConverter.ToUInt32(data, offset);
|
||||
offset += 4;
|
||||
|
||||
var addressSize = data[offset++];
|
||||
|
||||
// Parse DIEs (Debug Information Entries)
|
||||
while (offset < unitEnd)
|
||||
{
|
||||
var abbrevCode = ReadULEB128(data, ref offset);
|
||||
if (abbrevCode == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!abbreviations.TryGetValue(abbrevCode, out var abbrev))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (abbrev.Tag == DW_TAG_subprogram)
|
||||
{
|
||||
var func = ParseSubprogram(data, ref offset, abbrev, stringTable);
|
||||
if (func is not null)
|
||||
{
|
||||
functions.Add(func);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Skip attributes for other tags
|
||||
SkipAttributes(data, ref offset, abbrev, addressSize);
|
||||
}
|
||||
}
|
||||
|
||||
offset = unitEnd;
|
||||
}
|
||||
}
|
||||
|
||||
private DwarfFunction? ParseSubprogram(
|
||||
byte[] data,
|
||||
ref int offset,
|
||||
DwarfAbbreviation abbrev,
|
||||
byte[] stringTable)
|
||||
{
|
||||
string? name = null;
|
||||
string? linkageName = null;
|
||||
ulong lowPc = 0;
|
||||
ulong highPc = 0;
|
||||
uint declFile = 0;
|
||||
uint declLine = 0;
|
||||
bool isExternal = false;
|
||||
|
||||
foreach (var (attrName, attrForm) in abbrev.Attributes)
|
||||
{
|
||||
var value = ReadAttributeValue(data, ref offset, attrForm, stringTable);
|
||||
|
||||
switch (attrName)
|
||||
{
|
||||
case DW_AT_name:
|
||||
name = value as string;
|
||||
break;
|
||||
case DW_AT_linkage_name:
|
||||
linkageName = value as string;
|
||||
break;
|
||||
case DW_AT_low_pc:
|
||||
lowPc = Convert.ToUInt64(value);
|
||||
break;
|
||||
case DW_AT_high_pc:
|
||||
highPc = Convert.ToUInt64(value);
|
||||
break;
|
||||
case DW_AT_decl_file:
|
||||
declFile = Convert.ToUInt32(value);
|
||||
break;
|
||||
case DW_AT_decl_line:
|
||||
declLine = Convert.ToUInt32(value);
|
||||
break;
|
||||
case DW_AT_external:
|
||||
isExternal = value is true or 1 or 1UL;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(linkageName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DwarfFunction
|
||||
{
|
||||
Name = name ?? linkageName ?? "unknown",
|
||||
LinkageName = linkageName,
|
||||
LowPc = lowPc,
|
||||
HighPc = highPc,
|
||||
DeclFile = declFile,
|
||||
DeclLine = declLine,
|
||||
IsExternal = isExternal
|
||||
};
|
||||
}
|
||||
|
||||
private object? ReadAttributeValue(byte[] data, ref int offset, ushort form, byte[] stringTable)
|
||||
{
|
||||
return form switch
|
||||
{
|
||||
0x08 => ReadNullTerminatedString(data, offset, out offset), // DW_FORM_string
|
||||
0x0e => ReadNullTerminatedString(stringTable, (int)BitConverter.ToUInt32(data, offset += 4) - 4), // DW_FORM_strp
|
||||
0x01 => data[offset++] != 0, // DW_FORM_flag
|
||||
0x19 => true, // DW_FORM_flag_present
|
||||
0x05 => BitConverter.ToUInt16(data, offset += 2) - 2, // DW_FORM_data2
|
||||
0x06 => BitConverter.ToUInt32(data, offset += 4) - 4, // DW_FORM_data4
|
||||
0x07 => BitConverter.ToUInt64(data, offset += 8) - 8, // DW_FORM_data8
|
||||
0x0b => (ulong)data[offset++], // DW_FORM_data1
|
||||
0x0f => ReadULEB128(data, ref offset), // DW_FORM_udata
|
||||
0x10 => ReadSLEB128(data, ref offset), // DW_FORM_sdata
|
||||
0x17 => BitConverter.ToUInt32(data, offset += 4) - 4, // DW_FORM_sec_offset
|
||||
0x1f => BitConverter.ToUInt32(data, offset += 4) - 4, // DW_FORM_ref4
|
||||
_ => SkipFormValue(data, ref offset, form)
|
||||
};
|
||||
}
|
||||
|
||||
private void SkipAttributes(byte[] data, ref int offset, DwarfAbbreviation abbrev, byte addressSize)
|
||||
{
|
||||
foreach (var (_, form) in abbrev.Attributes)
|
||||
{
|
||||
SkipFormValue(data, ref offset, form);
|
||||
}
|
||||
}
|
||||
|
||||
private object? SkipFormValue(byte[] data, ref int offset, ushort form)
|
||||
{
|
||||
switch (form)
|
||||
{
|
||||
case 0x01: // DW_FORM_addr
|
||||
case 0x17: // DW_FORM_sec_offset
|
||||
case 0x06: // DW_FORM_data4
|
||||
case 0x1f: // DW_FORM_ref4
|
||||
offset += 4;
|
||||
break;
|
||||
case 0x07: // DW_FORM_data8
|
||||
case 0x0e: // DW_FORM_strp (64-bit)
|
||||
offset += 8;
|
||||
break;
|
||||
case 0x05: // DW_FORM_data2
|
||||
offset += 2;
|
||||
break;
|
||||
case 0x0b: // DW_FORM_data1
|
||||
case 0x19: // DW_FORM_flag_present
|
||||
offset += 1;
|
||||
break;
|
||||
case 0x08: // DW_FORM_string
|
||||
while (offset < data.Length && data[offset] != 0) offset++;
|
||||
offset++;
|
||||
break;
|
||||
case 0x0f: // DW_FORM_udata
|
||||
case 0x10: // DW_FORM_sdata
|
||||
ReadULEB128(data, ref offset);
|
||||
break;
|
||||
default:
|
||||
_logger.LogDebug("Unknown DWARF form: 0x{Form:X2}", form);
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ulong ReadULEB128(byte[] data, ref int offset)
|
||||
{
|
||||
ulong result = 0;
|
||||
int shift = 0;
|
||||
byte b;
|
||||
do
|
||||
{
|
||||
b = data[offset++];
|
||||
result |= (ulong)(b & 0x7F) << shift;
|
||||
shift += 7;
|
||||
} while ((b & 0x80) != 0 && offset < data.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static long ReadSLEB128(byte[] data, ref int offset)
|
||||
{
|
||||
long result = 0;
|
||||
int shift = 0;
|
||||
byte b;
|
||||
do
|
||||
{
|
||||
b = data[offset++];
|
||||
result |= (long)(b & 0x7F) << shift;
|
||||
shift += 7;
|
||||
} while ((b & 0x80) != 0 && offset < data.Length);
|
||||
|
||||
if (shift < 64 && (b & 0x40) != 0)
|
||||
{
|
||||
result |= -(1L << shift);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ReadNullTerminatedString(byte[] data, int offset)
|
||||
{
|
||||
if (offset < 0 || offset >= data.Length)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var end = offset;
|
||||
while (end < data.Length && data[end] != 0)
|
||||
{
|
||||
end++;
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(data, offset, end - offset);
|
||||
}
|
||||
|
||||
private static string ReadNullTerminatedString(byte[] data, int offset, out int newOffset)
|
||||
{
|
||||
var result = ReadNullTerminatedString(data, offset);
|
||||
newOffset = offset + result.Length + 1;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ELF section header info.
|
||||
/// </summary>
|
||||
internal sealed record ElfSection
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public uint Type { get; init; }
|
||||
public long Offset { get; init; }
|
||||
public long Size { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DWARF abbreviation table entry.
|
||||
/// </summary>
|
||||
internal sealed record DwarfAbbreviation
|
||||
{
|
||||
public ulong Code { get; init; }
|
||||
public ushort Tag { get; init; }
|
||||
public bool HasChildren { get; init; }
|
||||
public List<(ushort Name, ushort Form)> Attributes { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DWARF debug information result.
|
||||
/// </summary>
|
||||
public sealed record DwarfDebugInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Functions found in DWARF debug info.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DwarfFunction> Functions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Source files referenced in debug info.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SourceFiles { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A function from DWARF debug info.
|
||||
/// </summary>
|
||||
public sealed record DwarfFunction
|
||||
{
|
||||
/// <summary>
|
||||
/// Function name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mangled/linkage name if available.
|
||||
/// </summary>
|
||||
public string? LinkageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Low PC (start address).
|
||||
/// </summary>
|
||||
public ulong LowPc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// High PC (end address or size).
|
||||
/// </summary>
|
||||
public ulong HighPc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Declaration file index.
|
||||
/// </summary>
|
||||
public uint DeclFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Declaration line number.
|
||||
/// </summary>
|
||||
public uint DeclLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the function is externally visible.
|
||||
/// </summary>
|
||||
public bool IsExternal { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BunCallGraphExtractor.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Call graph extractor for Bun runtime (TypeScript/JavaScript).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Bun;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call graphs from Bun projects using AST analysis.
|
||||
/// Supports Elysia and Bun.serve() entrypoint detection.
|
||||
/// </summary>
|
||||
public sealed class BunCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly ILogger<BunCallGraphExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly BunEntrypointClassifier _entrypointClassifier;
|
||||
private readonly BunSinkMatcher _sinkMatcher;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public BunCallGraphExtractor(
|
||||
ILogger<BunCallGraphExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_entrypointClassifier = new BunEntrypointClassifier();
|
||||
_sinkMatcher = new BunSinkMatcher();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Language => "bun";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (!string.Equals(request.Language, Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"Expected language '{Language}', got '{request.Language}'.", nameof(request));
|
||||
}
|
||||
|
||||
var targetPath = Path.GetFullPath(request.TargetPath);
|
||||
_logger.LogDebug("Starting Bun call graph extraction for {Path}", targetPath);
|
||||
|
||||
// Check for pre-computed trace from external tool
|
||||
var tracePath = ResolveTracePath(targetPath);
|
||||
if (tracePath is not null && File.Exists(tracePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(tracePath);
|
||||
var trace = await JsonSerializer.DeserializeAsync<BunTraceDocument>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (trace is not null)
|
||||
{
|
||||
return BuildFromTrace(request.ScanId, trace);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read Bun trace file {Path}", tracePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to source-based analysis
|
||||
return await ExtractFromSourceAsync(request.ScanId, targetPath, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<CallGraphSnapshot> ExtractFromSourceAsync(
|
||||
string scanId,
|
||||
string targetPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var nodes = new List<CallGraphNode>();
|
||||
var edges = new List<CallGraphEdge>();
|
||||
var entrypointIds = new List<string>();
|
||||
var sinkIds = new List<string>();
|
||||
|
||||
// Find TypeScript/JavaScript files
|
||||
var files = Directory.Exists(targetPath)
|
||||
? Directory.EnumerateFiles(targetPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => f.EndsWith(".ts", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".tsx", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".js", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".jsx", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(f => !f.Contains("node_modules", StringComparison.OrdinalIgnoreCase))
|
||||
: Enumerable.Empty<string>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
ExtractFromFile(file, content, targetPath, nodes, edges, entrypointIds, sinkIds);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Skipping file {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: [..nodes],
|
||||
Edges: [..edges],
|
||||
EntrypointIds: [..entrypointIds],
|
||||
SinkIds: [..sinkIds]);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private void ExtractFromFile(
|
||||
string filePath,
|
||||
string content,
|
||||
string projectPath,
|
||||
List<CallGraphNode> nodes,
|
||||
List<CallGraphEdge> edges,
|
||||
List<string> entrypointIds,
|
||||
List<string> sinkIds)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(projectPath, filePath);
|
||||
var projectName = Path.GetFileName(projectPath);
|
||||
|
||||
// Detect Bun.serve() entrypoints
|
||||
if (content.Contains("Bun.serve", StringComparison.Ordinal))
|
||||
{
|
||||
var lineNumber = content[..content.IndexOf("Bun.serve", StringComparison.Ordinal)]
|
||||
.Count(c => c == '\n') + 1;
|
||||
|
||||
var nodeId = $"bun:{projectName}/{relativePath}:serve";
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: "Bun.serve",
|
||||
File: filePath,
|
||||
Line: lineNumber,
|
||||
Package: projectName,
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null));
|
||||
entrypointIds.Add(nodeId);
|
||||
}
|
||||
|
||||
// Detect Elysia routes
|
||||
DetectElysiaRoutes(filePath, content, projectPath, projectName, nodes, entrypointIds);
|
||||
|
||||
// Detect Hono routes (commonly used with Bun)
|
||||
DetectHonoRoutes(filePath, content, projectPath, projectName, nodes, entrypointIds);
|
||||
|
||||
// Detect sinks
|
||||
DetectSinks(filePath, content, projectPath, projectName, nodes, sinkIds);
|
||||
}
|
||||
|
||||
private void DetectElysiaRoutes(
|
||||
string filePath,
|
||||
string content,
|
||||
string projectPath,
|
||||
string projectName,
|
||||
List<CallGraphNode> nodes,
|
||||
List<string> entrypointIds)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(projectPath, filePath);
|
||||
|
||||
// Elysia pattern: .get(), .post(), .put(), .delete(), .patch()
|
||||
var methods = new[] { "get", "post", "put", "delete", "patch", "options", "head" };
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var pattern = $".{method}(";
|
||||
var index = 0;
|
||||
while ((index = content.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var lineNumber = content[..index].Count(c => c == '\n') + 1;
|
||||
|
||||
var nodeId = $"bun:{projectName}/{relativePath}:{method}_{lineNumber}";
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: $"elysia.{method}",
|
||||
File: filePath,
|
||||
Line: lineNumber,
|
||||
Package: projectName,
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null));
|
||||
entrypointIds.Add(nodeId);
|
||||
|
||||
index += pattern.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DetectHonoRoutes(
|
||||
string filePath,
|
||||
string content,
|
||||
string projectPath,
|
||||
string projectName,
|
||||
List<CallGraphNode> nodes,
|
||||
List<string> entrypointIds)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(projectPath, filePath);
|
||||
|
||||
// Hono also uses .get(), .post() etc.
|
||||
if (!content.Contains("Hono", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var methods = new[] { "get", "post", "put", "delete", "patch", "all" };
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var pattern = $".{method}(";
|
||||
var index = 0;
|
||||
while ((index = content.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var lineNumber = content[..index].Count(c => c == '\n') + 1;
|
||||
|
||||
var nodeId = $"bun:{projectName}/{relativePath}:hono_{method}_{lineNumber}";
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: $"hono.{method}",
|
||||
File: filePath,
|
||||
Line: lineNumber,
|
||||
Package: projectName,
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null));
|
||||
entrypointIds.Add(nodeId);
|
||||
|
||||
index += pattern.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DetectSinks(
|
||||
string filePath,
|
||||
string content,
|
||||
string projectPath,
|
||||
string projectName,
|
||||
List<CallGraphNode> nodes,
|
||||
List<string> sinkIds)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(projectPath, filePath);
|
||||
var sinkPatterns = _sinkMatcher.GetSinkPatterns();
|
||||
|
||||
foreach (var (pattern, category) in sinkPatterns)
|
||||
{
|
||||
var index = 0;
|
||||
while ((index = content.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var lineNumber = content[..index].Count(c => c == '\n') + 1;
|
||||
|
||||
var nodeId = $"bun:{projectName}/{relativePath}:sink_{lineNumber}";
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: pattern,
|
||||
File: filePath,
|
||||
Line: lineNumber,
|
||||
Package: projectName,
|
||||
Visibility: Visibility.Private,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: category));
|
||||
sinkIds.Add(nodeId);
|
||||
|
||||
index += pattern.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildFromTrace(string scanId, BunTraceDocument trace)
|
||||
{
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var nodes = new List<CallGraphNode>();
|
||||
var edges = new List<CallGraphEdge>();
|
||||
var entrypointIds = new List<string>();
|
||||
var sinkIds = new List<string>();
|
||||
|
||||
foreach (var node in trace.Nodes ?? [])
|
||||
{
|
||||
var entrypointType = _entrypointClassifier.Classify(node.Symbol, node.Annotations);
|
||||
var sinkCategory = _sinkMatcher.Match(node.Symbol);
|
||||
|
||||
var cgNode = new CallGraphNode(
|
||||
NodeId: node.Id,
|
||||
Symbol: node.Symbol,
|
||||
File: node.File ?? string.Empty,
|
||||
Line: node.Line,
|
||||
Package: node.Package ?? "unknown",
|
||||
Visibility: node.IsExported ? Visibility.Public : Visibility.Private,
|
||||
IsEntrypoint: entrypointType.HasValue,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: sinkCategory.HasValue,
|
||||
SinkCategory: sinkCategory);
|
||||
|
||||
nodes.Add(cgNode);
|
||||
|
||||
if (entrypointType.HasValue)
|
||||
{
|
||||
entrypointIds.Add(node.Id);
|
||||
}
|
||||
|
||||
if (sinkCategory.HasValue)
|
||||
{
|
||||
sinkIds.Add(node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var edge in trace.Edges ?? [])
|
||||
{
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: edge.Source,
|
||||
TargetId: edge.Target,
|
||||
CallKind: MapCallKind(edge.Type),
|
||||
CallSite: null));
|
||||
}
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: [..nodes],
|
||||
Edges: [..edges],
|
||||
EntrypointIds: [..entrypointIds],
|
||||
SinkIds: [..sinkIds]);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private static string? ResolveTracePath(string targetPath)
|
||||
{
|
||||
if (Directory.Exists(targetPath))
|
||||
{
|
||||
return Path.Combine(targetPath, ".stella", "bun-trace.json");
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(targetPath);
|
||||
return dir is null ? null : Path.Combine(dir, ".stella", "bun-trace.json");
|
||||
}
|
||||
|
||||
private static CallKind MapCallKind(string? type) => type?.ToLowerInvariant() switch
|
||||
{
|
||||
"direct" => CallKind.Direct,
|
||||
"virtual" => CallKind.Virtual,
|
||||
"callback" => CallKind.Delegate,
|
||||
"async" => CallKind.Delegate,
|
||||
_ => CallKind.Dynamic
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record BunTraceDocument(
|
||||
List<BunTraceNode>? Nodes,
|
||||
List<BunTraceEdge>? Edges);
|
||||
|
||||
internal sealed record BunTraceNode(
|
||||
string Id,
|
||||
string Symbol,
|
||||
string? File,
|
||||
int Line,
|
||||
string? Package,
|
||||
bool IsExported,
|
||||
string[]? Annotations);
|
||||
|
||||
internal sealed record BunTraceEdge(
|
||||
string Source,
|
||||
string Target,
|
||||
string? Type,
|
||||
int Line);
|
||||
@@ -0,0 +1,100 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BunEntrypointClassifier.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Classifies Bun functions as entrypoints based on framework patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Bun;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies Bun functions as entrypoints based on Elysia and Bun.serve patterns.
|
||||
/// </summary>
|
||||
internal sealed class BunEntrypointClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Elysia HTTP method decorators and patterns.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ElysiaHttpMethods = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"get", "post", "put", "delete", "patch", "options", "head", "all"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Bun.serve entry patterns.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> BunServePatterns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Bun.serve",
|
||||
"serve",
|
||||
"fetch"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a function based on its symbol and annotations.
|
||||
/// </summary>
|
||||
public EntrypointType? Classify(string symbol, string[]? annotations)
|
||||
{
|
||||
if (string.IsNullOrEmpty(symbol))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check Bun.serve patterns
|
||||
if (BunServePatterns.Contains(symbol))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Check for Elysia route handlers
|
||||
var symbolLower = symbol.ToLowerInvariant();
|
||||
if (symbolLower.StartsWith("elysia.", StringComparison.Ordinal))
|
||||
{
|
||||
var method = symbolLower["elysia.".Length..];
|
||||
if (ElysiaHttpMethods.Contains(method))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Hono route handlers
|
||||
if (symbolLower.StartsWith("hono.", StringComparison.Ordinal))
|
||||
{
|
||||
var method = symbolLower["hono.".Length..];
|
||||
if (ElysiaHttpMethods.Contains(method))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Check annotations
|
||||
if (annotations is not null)
|
||||
{
|
||||
foreach (var annotation in annotations)
|
||||
{
|
||||
if (annotation.Contains("@route", StringComparison.OrdinalIgnoreCase) ||
|
||||
annotation.Contains("@get", StringComparison.OrdinalIgnoreCase) ||
|
||||
annotation.Contains("@post", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
if (annotation.Contains("@cron", StringComparison.OrdinalIgnoreCase) ||
|
||||
annotation.Contains("@scheduled", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.ScheduledJob;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for WebSocket handlers
|
||||
if (symbolLower.Contains("websocket", StringComparison.OrdinalIgnoreCase) ||
|
||||
symbolLower.Contains("ws.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.EventHandler;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BunSinkMatcher.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Matches Bun/JS function calls to security sink categories.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Bun;
|
||||
|
||||
/// <summary>
|
||||
/// Matches Bun/JavaScript function calls to security sink categories.
|
||||
/// Covers Bun-specific APIs and common JavaScript security sinks.
|
||||
/// </summary>
|
||||
internal sealed class BunSinkMatcher
|
||||
{
|
||||
private static readonly Dictionary<string, SinkCategory> SinkPatterns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Command execution
|
||||
["Bun.spawn"] = SinkCategory.CmdExec,
|
||||
["Bun.spawnSync"] = SinkCategory.CmdExec,
|
||||
["child_process.exec"] = SinkCategory.CmdExec,
|
||||
["child_process.execSync"] = SinkCategory.CmdExec,
|
||||
["child_process.spawn"] = SinkCategory.CmdExec,
|
||||
["child_process.spawnSync"] = SinkCategory.CmdExec,
|
||||
["execSync"] = SinkCategory.CmdExec,
|
||||
["exec("] = SinkCategory.CmdExec,
|
||||
|
||||
// SQL (raw queries)
|
||||
["$queryRaw"] = SinkCategory.SqlRaw,
|
||||
["$executeRaw"] = SinkCategory.SqlRaw,
|
||||
[".query("] = SinkCategory.SqlRaw,
|
||||
[".execute("] = SinkCategory.SqlRaw,
|
||||
["sql`"] = SinkCategory.SqlRaw,
|
||||
|
||||
// File operations
|
||||
["Bun.write"] = SinkCategory.FileWrite,
|
||||
["Bun.file"] = SinkCategory.FileWrite,
|
||||
["fs.writeFile"] = SinkCategory.FileWrite,
|
||||
["fs.writeFileSync"] = SinkCategory.FileWrite,
|
||||
["fs.appendFile"] = SinkCategory.FileWrite,
|
||||
|
||||
// Path traversal
|
||||
["path.join"] = SinkCategory.PathTraversal,
|
||||
["path.resolve"] = SinkCategory.PathTraversal,
|
||||
|
||||
// SSRF
|
||||
["fetch("] = SinkCategory.Ssrf,
|
||||
["Bun.fetch"] = SinkCategory.Ssrf,
|
||||
|
||||
// Deserialization
|
||||
["JSON.parse"] = SinkCategory.UnsafeDeser,
|
||||
["eval("] = SinkCategory.CodeInjection,
|
||||
["Function("] = SinkCategory.CodeInjection,
|
||||
["new Function"] = SinkCategory.CodeInjection,
|
||||
|
||||
// Template injection
|
||||
["innerHTML"] = SinkCategory.TemplateInjection,
|
||||
["dangerouslySetInnerHTML"] = SinkCategory.TemplateInjection,
|
||||
|
||||
// Crypto weak
|
||||
["crypto.createHash('md5"] = SinkCategory.CryptoWeak,
|
||||
["crypto.createHash('sha1"] = SinkCategory.CryptoWeak,
|
||||
["createHash('md5"] = SinkCategory.CryptoWeak,
|
||||
["createHash('sha1"] = SinkCategory.CryptoWeak,
|
||||
|
||||
// Reflection
|
||||
["Reflect.construct"] = SinkCategory.Reflection,
|
||||
["Object.assign"] = SinkCategory.Reflection,
|
||||
|
||||
// Open redirect
|
||||
["location.href"] = SinkCategory.OpenRedirect,
|
||||
["window.location"] = SinkCategory.OpenRedirect,
|
||||
["res.redirect"] = SinkCategory.OpenRedirect,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Matches a symbol to a sink category.
|
||||
/// </summary>
|
||||
public SinkCategory? Match(string symbol)
|
||||
{
|
||||
if (string.IsNullOrEmpty(symbol))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SinkPatterns.TryGetValue(symbol, out var category))
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
// Check for partial matches
|
||||
foreach (var (pattern, cat) in SinkPatterns)
|
||||
{
|
||||
if (symbol.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return cat;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all sink patterns for source-level detection.
|
||||
/// </summary>
|
||||
public IEnumerable<(string Pattern, SinkCategory Category)> GetSinkPatterns()
|
||||
{
|
||||
return SinkPatterns.Select(kv => (kv.Key, kv.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DenoCallGraphExtractor.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Call graph extractor for Deno runtime (TypeScript/JavaScript).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Deno;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call graphs from Deno projects using AST analysis.
|
||||
/// Supports Oak, Fresh, and Hono entrypoint detection.
|
||||
/// </summary>
|
||||
public sealed class DenoCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly ILogger<DenoCallGraphExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DenoEntrypointClassifier _entrypointClassifier;
|
||||
private readonly DenoSinkMatcher _sinkMatcher;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public DenoCallGraphExtractor(
|
||||
ILogger<DenoCallGraphExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_entrypointClassifier = new DenoEntrypointClassifier();
|
||||
_sinkMatcher = new DenoSinkMatcher();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Language => "deno";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (!string.Equals(request.Language, Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"Expected language '{Language}', got '{request.Language}'.", nameof(request));
|
||||
}
|
||||
|
||||
var targetPath = Path.GetFullPath(request.TargetPath);
|
||||
_logger.LogDebug("Starting Deno call graph extraction for {Path}", targetPath);
|
||||
|
||||
// Check for pre-computed trace from external tool
|
||||
var tracePath = ResolveTracePath(targetPath);
|
||||
if (tracePath is not null && File.Exists(tracePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(tracePath);
|
||||
var trace = await JsonSerializer.DeserializeAsync<DenoTraceDocument>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (trace is not null)
|
||||
{
|
||||
return BuildFromTrace(request.ScanId, trace);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read Deno trace file {Path}", tracePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to source-based analysis
|
||||
return await ExtractFromSourceAsync(request.ScanId, targetPath, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<CallGraphSnapshot> ExtractFromSourceAsync(
|
||||
string scanId,
|
||||
string targetPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var nodes = new List<CallGraphNode>();
|
||||
var edges = new List<CallGraphEdge>();
|
||||
var entrypointIds = new List<string>();
|
||||
var sinkIds = new List<string>();
|
||||
|
||||
// Find TypeScript/JavaScript files
|
||||
var files = Directory.Exists(targetPath)
|
||||
? Directory.EnumerateFiles(targetPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => f.EndsWith(".ts", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".tsx", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".js", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".jsx", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(f => !f.Contains("node_modules", StringComparison.OrdinalIgnoreCase))
|
||||
: Enumerable.Empty<string>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
ExtractFromFile(file, content, targetPath, nodes, edges, entrypointIds, sinkIds);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Skipping file {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: [..nodes],
|
||||
Edges: [..edges],
|
||||
EntrypointIds: [..entrypointIds],
|
||||
SinkIds: [..sinkIds]);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private void ExtractFromFile(
|
||||
string filePath,
|
||||
string content,
|
||||
string projectPath,
|
||||
List<CallGraphNode> nodes,
|
||||
List<CallGraphEdge> edges,
|
||||
List<string> entrypointIds,
|
||||
List<string> sinkIds)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(projectPath, filePath);
|
||||
var projectName = Path.GetFileName(projectPath);
|
||||
|
||||
// Detect Deno.serve() entrypoints
|
||||
if (content.Contains("Deno.serve", StringComparison.Ordinal))
|
||||
{
|
||||
var lineNumber = content[..content.IndexOf("Deno.serve", StringComparison.Ordinal)]
|
||||
.Count(c => c == '\n') + 1;
|
||||
|
||||
var nodeId = $"deno:{projectName}/{relativePath}:serve";
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: "Deno.serve",
|
||||
File: filePath,
|
||||
Line: lineNumber,
|
||||
Package: projectName,
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null));
|
||||
entrypointIds.Add(nodeId);
|
||||
}
|
||||
|
||||
// Detect Oak routes
|
||||
DetectOakRoutes(filePath, content, projectPath, projectName, nodes, entrypointIds);
|
||||
|
||||
// Detect Fresh routes (file-based routing)
|
||||
DetectFreshRoutes(filePath, content, projectPath, projectName, nodes, entrypointIds);
|
||||
|
||||
// Detect Hono routes
|
||||
DetectHonoRoutes(filePath, content, projectPath, projectName, nodes, entrypointIds);
|
||||
|
||||
// Detect sinks
|
||||
DetectSinks(filePath, content, projectPath, projectName, nodes, sinkIds);
|
||||
}
|
||||
|
||||
private void DetectOakRoutes(
|
||||
string filePath,
|
||||
string content,
|
||||
string projectPath,
|
||||
string projectName,
|
||||
List<CallGraphNode> nodes,
|
||||
List<string> entrypointIds)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(projectPath, filePath);
|
||||
|
||||
// Oak Router pattern: router.get(), router.post(), etc.
|
||||
if (!content.Contains("Router", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var methods = new[] { "get", "post", "put", "delete", "patch", "all" };
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var pattern = $".{method}(";
|
||||
var index = 0;
|
||||
while ((index = content.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var lineNumber = content[..index].Count(c => c == '\n') + 1;
|
||||
|
||||
var nodeId = $"deno:{projectName}/{relativePath}:oak_{method}_{lineNumber}";
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: $"oak.{method}",
|
||||
File: filePath,
|
||||
Line: lineNumber,
|
||||
Package: projectName,
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null));
|
||||
entrypointIds.Add(nodeId);
|
||||
|
||||
index += pattern.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DetectFreshRoutes(
|
||||
string filePath,
|
||||
string content,
|
||||
string projectPath,
|
||||
string projectName,
|
||||
List<CallGraphNode> nodes,
|
||||
List<string> entrypointIds)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(projectPath, filePath);
|
||||
|
||||
// Fresh uses file-based routing in routes/ directory
|
||||
if (!relativePath.Contains("routes", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect handler exports
|
||||
var handlerPatterns = new[] { "export const handler", "export function handler", "export default" };
|
||||
|
||||
foreach (var pattern in handlerPatterns)
|
||||
{
|
||||
if (content.Contains(pattern, StringComparison.Ordinal))
|
||||
{
|
||||
var index = content.IndexOf(pattern, StringComparison.Ordinal);
|
||||
var lineNumber = content[..index].Count(c => c == '\n') + 1;
|
||||
|
||||
var nodeId = $"deno:{projectName}/{relativePath}:fresh_handler";
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: "fresh.handler",
|
||||
File: filePath,
|
||||
Line: lineNumber,
|
||||
Package: projectName,
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null));
|
||||
entrypointIds.Add(nodeId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DetectHonoRoutes(
|
||||
string filePath,
|
||||
string content,
|
||||
string projectPath,
|
||||
string projectName,
|
||||
List<CallGraphNode> nodes,
|
||||
List<string> entrypointIds)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(projectPath, filePath);
|
||||
|
||||
if (!content.Contains("Hono", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var methods = new[] { "get", "post", "put", "delete", "patch", "all" };
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var pattern = $".{method}(";
|
||||
var index = 0;
|
||||
while ((index = content.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var lineNumber = content[..index].Count(c => c == '\n') + 1;
|
||||
|
||||
var nodeId = $"deno:{projectName}/{relativePath}:hono_{method}_{lineNumber}";
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: $"hono.{method}",
|
||||
File: filePath,
|
||||
Line: lineNumber,
|
||||
Package: projectName,
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null));
|
||||
entrypointIds.Add(nodeId);
|
||||
|
||||
index += pattern.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DetectSinks(
|
||||
string filePath,
|
||||
string content,
|
||||
string projectPath,
|
||||
string projectName,
|
||||
List<CallGraphNode> nodes,
|
||||
List<string> sinkIds)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(projectPath, filePath);
|
||||
var sinkPatterns = _sinkMatcher.GetSinkPatterns();
|
||||
|
||||
foreach (var (pattern, category) in sinkPatterns)
|
||||
{
|
||||
var index = 0;
|
||||
while ((index = content.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var lineNumber = content[..index].Count(c => c == '\n') + 1;
|
||||
|
||||
var nodeId = $"deno:{projectName}/{relativePath}:sink_{lineNumber}";
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: pattern,
|
||||
File: filePath,
|
||||
Line: lineNumber,
|
||||
Package: projectName,
|
||||
Visibility: Visibility.Private,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: category));
|
||||
sinkIds.Add(nodeId);
|
||||
|
||||
index += pattern.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildFromTrace(string scanId, DenoTraceDocument trace)
|
||||
{
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var nodes = new List<CallGraphNode>();
|
||||
var edges = new List<CallGraphEdge>();
|
||||
var entrypointIds = new List<string>();
|
||||
var sinkIds = new List<string>();
|
||||
|
||||
foreach (var node in trace.Nodes ?? [])
|
||||
{
|
||||
var entrypointType = _entrypointClassifier.Classify(node.Symbol, node.Annotations);
|
||||
var sinkCategory = _sinkMatcher.Match(node.Symbol);
|
||||
|
||||
var cgNode = new CallGraphNode(
|
||||
NodeId: node.Id,
|
||||
Symbol: node.Symbol,
|
||||
File: node.File ?? string.Empty,
|
||||
Line: node.Line,
|
||||
Package: node.Package ?? "unknown",
|
||||
Visibility: node.IsExported ? Visibility.Public : Visibility.Private,
|
||||
IsEntrypoint: entrypointType.HasValue,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: sinkCategory.HasValue,
|
||||
SinkCategory: sinkCategory);
|
||||
|
||||
nodes.Add(cgNode);
|
||||
|
||||
if (entrypointType.HasValue)
|
||||
{
|
||||
entrypointIds.Add(node.Id);
|
||||
}
|
||||
|
||||
if (sinkCategory.HasValue)
|
||||
{
|
||||
sinkIds.Add(node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var edge in trace.Edges ?? [])
|
||||
{
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: edge.Source,
|
||||
TargetId: edge.Target,
|
||||
CallKind: MapCallKind(edge.Type),
|
||||
CallSite: null));
|
||||
}
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: [..nodes],
|
||||
Edges: [..edges],
|
||||
EntrypointIds: [..entrypointIds],
|
||||
SinkIds: [..sinkIds]);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private static string? ResolveTracePath(string targetPath)
|
||||
{
|
||||
if (Directory.Exists(targetPath))
|
||||
{
|
||||
return Path.Combine(targetPath, ".stella", "deno-trace.json");
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(targetPath);
|
||||
return dir is null ? null : Path.Combine(dir, ".stella", "deno-trace.json");
|
||||
}
|
||||
|
||||
private static CallKind MapCallKind(string? type) => type?.ToLowerInvariant() switch
|
||||
{
|
||||
"direct" => CallKind.Direct,
|
||||
"virtual" => CallKind.Virtual,
|
||||
"callback" => CallKind.Delegate,
|
||||
"async" => CallKind.Delegate,
|
||||
_ => CallKind.Dynamic
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record DenoTraceDocument(
|
||||
List<DenoTraceNode>? Nodes,
|
||||
List<DenoTraceEdge>? Edges);
|
||||
|
||||
internal sealed record DenoTraceNode(
|
||||
string Id,
|
||||
string Symbol,
|
||||
string? File,
|
||||
int Line,
|
||||
string? Package,
|
||||
bool IsExported,
|
||||
string[]? Annotations);
|
||||
|
||||
internal sealed record DenoTraceEdge(
|
||||
string Source,
|
||||
string Target,
|
||||
string? Type,
|
||||
int Line);
|
||||
@@ -0,0 +1,126 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DenoEntrypointClassifier.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Classifies Deno functions as entrypoints based on framework patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Deno;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies Deno functions as entrypoints based on Oak, Fresh, and Hono patterns.
|
||||
/// </summary>
|
||||
internal sealed class DenoEntrypointClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP method patterns.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> HttpMethods = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"get", "post", "put", "delete", "patch", "options", "head", "all"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Deno.serve entry patterns.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> DenoServePatterns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Deno.serve",
|
||||
"serve",
|
||||
"fetch"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fresh handler patterns.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> FreshPatterns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"handler",
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"PATCH"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a function based on its symbol and annotations.
|
||||
/// </summary>
|
||||
public EntrypointType? Classify(string symbol, string[]? annotations)
|
||||
{
|
||||
if (string.IsNullOrEmpty(symbol))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check Deno.serve patterns
|
||||
if (DenoServePatterns.Contains(symbol))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Check for Fresh handler patterns
|
||||
if (FreshPatterns.Contains(symbol))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Check for Oak route handlers
|
||||
var symbolLower = symbol.ToLowerInvariant();
|
||||
if (symbolLower.StartsWith("oak.", StringComparison.Ordinal))
|
||||
{
|
||||
var method = symbolLower["oak.".Length..];
|
||||
if (HttpMethods.Contains(method))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Hono route handlers
|
||||
if (symbolLower.StartsWith("hono.", StringComparison.Ordinal))
|
||||
{
|
||||
var method = symbolLower["hono.".Length..];
|
||||
if (HttpMethods.Contains(method))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Check annotations
|
||||
if (annotations is not null)
|
||||
{
|
||||
foreach (var annotation in annotations)
|
||||
{
|
||||
if (annotation.Contains("@route", StringComparison.OrdinalIgnoreCase) ||
|
||||
annotation.Contains("@get", StringComparison.OrdinalIgnoreCase) ||
|
||||
annotation.Contains("@post", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
if (annotation.Contains("@cron", StringComparison.OrdinalIgnoreCase) ||
|
||||
annotation.Contains("Deno.cron", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.ScheduledJob;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for WebSocket handlers
|
||||
if (symbolLower.Contains("websocket", StringComparison.OrdinalIgnoreCase) ||
|
||||
symbolLower.Contains("ws.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.EventHandler;
|
||||
}
|
||||
|
||||
// Check for Deno Deploy handlers
|
||||
if (symbolLower.Contains("deploy", StringComparison.OrdinalIgnoreCase) &&
|
||||
symbolLower.Contains("handler", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DenoSinkMatcher.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Matches Deno function calls to security sink categories.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Deno;
|
||||
|
||||
/// <summary>
|
||||
/// Matches Deno function calls to security sink categories.
|
||||
/// Covers Deno-specific APIs and common JavaScript security sinks.
|
||||
/// </summary>
|
||||
internal sealed class DenoSinkMatcher
|
||||
{
|
||||
private static readonly Dictionary<string, SinkCategory> SinkPatterns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Command execution
|
||||
["Deno.run"] = SinkCategory.CmdExec,
|
||||
["Deno.Command"] = SinkCategory.CmdExec,
|
||||
["new Deno.Command"] = SinkCategory.CmdExec,
|
||||
|
||||
// SQL (raw queries)
|
||||
["$queryRaw"] = SinkCategory.SqlRaw,
|
||||
["$executeRaw"] = SinkCategory.SqlRaw,
|
||||
[".query("] = SinkCategory.SqlRaw,
|
||||
[".execute("] = SinkCategory.SqlRaw,
|
||||
["sql`"] = SinkCategory.SqlRaw,
|
||||
|
||||
// File operations
|
||||
["Deno.writeFile"] = SinkCategory.FileWrite,
|
||||
["Deno.writeTextFile"] = SinkCategory.FileWrite,
|
||||
["Deno.create"] = SinkCategory.FileWrite,
|
||||
["Deno.open"] = SinkCategory.FileWrite,
|
||||
["Deno.truncate"] = SinkCategory.FileWrite,
|
||||
|
||||
// Path traversal
|
||||
["Deno.realPath"] = SinkCategory.PathTraversal,
|
||||
["path.join"] = SinkCategory.PathTraversal,
|
||||
["path.resolve"] = SinkCategory.PathTraversal,
|
||||
|
||||
// SSRF
|
||||
["fetch("] = SinkCategory.Ssrf,
|
||||
|
||||
// Deserialization
|
||||
["JSON.parse"] = SinkCategory.UnsafeDeser,
|
||||
["eval("] = SinkCategory.CodeInjection,
|
||||
["Function("] = SinkCategory.CodeInjection,
|
||||
["new Function"] = SinkCategory.CodeInjection,
|
||||
|
||||
// Template injection
|
||||
["innerHTML"] = SinkCategory.TemplateInjection,
|
||||
["dangerouslySetInnerHTML"] = SinkCategory.TemplateInjection,
|
||||
|
||||
// Crypto weak
|
||||
["crypto.subtle.digest"] = SinkCategory.CryptoWeak,
|
||||
|
||||
// Environment variables (potential secrets)
|
||||
["Deno.env.get"] = SinkCategory.AuthzBypass,
|
||||
|
||||
// Network - raw sockets
|
||||
["Deno.connect"] = SinkCategory.Ssrf,
|
||||
["Deno.connectTls"] = SinkCategory.Ssrf,
|
||||
["Deno.listen"] = SinkCategory.Ssrf,
|
||||
["Deno.listenTls"] = SinkCategory.Ssrf,
|
||||
|
||||
// Reflection
|
||||
["Reflect.construct"] = SinkCategory.Reflection,
|
||||
["Object.assign"] = SinkCategory.Reflection,
|
||||
|
||||
// Open redirect
|
||||
["Response.redirect"] = SinkCategory.OpenRedirect,
|
||||
["location.href"] = SinkCategory.OpenRedirect,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Matches a symbol to a sink category.
|
||||
/// </summary>
|
||||
public SinkCategory? Match(string symbol)
|
||||
{
|
||||
if (string.IsNullOrEmpty(symbol))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SinkPatterns.TryGetValue(symbol, out var category))
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
// Check for partial matches
|
||||
foreach (var (pattern, cat) in SinkPatterns)
|
||||
{
|
||||
if (symbol.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return cat;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all sink patterns for source-level detection.
|
||||
/// </summary>
|
||||
public IEnumerable<(string Pattern, SinkCategory Category)> GetSinkPatterns()
|
||||
{
|
||||
return SinkPatterns.Select(kv => (kv.Key, kv.Value));
|
||||
}
|
||||
}
|
||||
@@ -340,38 +340,6 @@ public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
private sealed class CallGraphEdgeComparer : IEqualityComparer<CallGraphEdge>
|
||||
{
|
||||
public static readonly CallGraphEdgeComparer Instance = new();
|
||||
|
||||
public bool Equals(CallGraphEdge? x, CallGraphEdge? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.SourceId, y.SourceId, StringComparison.Ordinal)
|
||||
&& string.Equals(x.TargetId, y.TargetId, StringComparison.Ordinal)
|
||||
&& x.CallKind == y.CallKind
|
||||
&& string.Equals(x.CallSite ?? string.Empty, y.CallSite ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(CallGraphEdge obj)
|
||||
{
|
||||
return HashCode.Combine(
|
||||
obj.SourceId,
|
||||
obj.TargetId,
|
||||
obj.CallKind,
|
||||
obj.CallSite ?? string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EntrypointClassifier
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GoCallGraphExtractor.cs
|
||||
// Sprint: SPRINT_3610_0002_0001_go_callgraph
|
||||
// Description: Go call graph extractor using SSA-based analysis via external tool.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Go;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call graphs from Go source code using SSA-based analysis.
|
||||
/// Invokes an external Go tool (stella-callgraph-go) for precise analysis.
|
||||
/// </summary>
|
||||
public sealed class GoCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly ILogger<GoCallGraphExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly GoEntrypointClassifier _entrypointClassifier;
|
||||
private readonly GoSinkMatcher _sinkMatcher;
|
||||
private readonly string _toolPath;
|
||||
|
||||
public GoCallGraphExtractor(
|
||||
ILogger<GoCallGraphExtractor> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
string? toolPath = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_entrypointClassifier = new GoEntrypointClassifier();
|
||||
_sinkMatcher = new GoSinkMatcher();
|
||||
_toolPath = toolPath ?? ResolveToolPath();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Language => "go";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!string.Equals(request.Language, Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"Expected language '{Language}', got '{request.Language}'.", nameof(request));
|
||||
}
|
||||
|
||||
var targetPath = Path.GetFullPath(request.TargetPath);
|
||||
var goModPath = FindGoMod(targetPath);
|
||||
|
||||
if (goModPath is null)
|
||||
{
|
||||
throw new FileNotFoundException($"No go.mod found at or above: {targetPath}");
|
||||
}
|
||||
|
||||
var moduleDir = Path.GetDirectoryName(goModPath)!;
|
||||
_logger.LogDebug("Starting Go call graph extraction for module at {Path}", moduleDir);
|
||||
|
||||
// Check if external tool is available
|
||||
if (!File.Exists(_toolPath))
|
||||
{
|
||||
_logger.LogWarning("External tool not found at {Path}, falling back to static analysis", _toolPath);
|
||||
return await FallbackStaticAnalysisAsync(request, moduleDir, cancellationToken);
|
||||
}
|
||||
|
||||
// Invoke external Go tool
|
||||
var toolOutput = await InvokeExternalToolAsync(moduleDir, cancellationToken);
|
||||
|
||||
if (toolOutput is null)
|
||||
{
|
||||
_logger.LogWarning("External tool failed, falling back to static analysis");
|
||||
return await FallbackStaticAnalysisAsync(request, moduleDir, cancellationToken);
|
||||
}
|
||||
|
||||
// Parse and convert to CallGraphSnapshot
|
||||
return ConvertToSnapshot(request.ScanId, toolOutput);
|
||||
}
|
||||
|
||||
private async Task<GoToolOutput?> InvokeExternalToolAsync(
|
||||
string moduleDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _toolPath,
|
||||
Arguments = $"--module \"{moduleDir}\" --format json",
|
||||
WorkingDirectory = moduleDir,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
var output = await outputTask;
|
||||
var error = await errorTask;
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
_logger.LogWarning("Go tool exited with code {Code}: {Error}", process.ExitCode, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<GoToolOutput>(output, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invoke Go tool");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CallGraphSnapshot> FallbackStaticAnalysisAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
string moduleDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Fallback: Parse Go source files statically for basic call graph
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
|
||||
var goFiles = Directory.EnumerateFiles(moduleDir, "*.go", SearchOption.AllDirectories)
|
||||
.Where(f => !f.Contains("_test.go") && !f.Contains("/vendor/") && !f.Contains("\\vendor\\"))
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("Found {Count} Go files for static analysis", goFiles.Count);
|
||||
|
||||
foreach (var goFile in goFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(goFile, cancellationToken);
|
||||
var relativePath = Path.GetRelativePath(moduleDir, goFile);
|
||||
var packageName = ExtractPackageName(content);
|
||||
|
||||
// Extract function declarations
|
||||
var functions = ExtractFunctions(content, relativePath, packageName);
|
||||
|
||||
foreach (var func in functions)
|
||||
{
|
||||
var entrypointType = _entrypointClassifier.Classify(func);
|
||||
var sinkCategory = _sinkMatcher.Match(func.Package, func.Name);
|
||||
|
||||
var node = new CallGraphNode(
|
||||
NodeId: func.NodeId,
|
||||
Symbol: $"{func.Package}.{func.Name}",
|
||||
File: relativePath,
|
||||
Line: func.Line,
|
||||
Package: func.Package,
|
||||
Visibility: func.IsExported ? Visibility.Public : Visibility.Private,
|
||||
IsEntrypoint: entrypointType.HasValue,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: sinkCategory.HasValue,
|
||||
SinkCategory: sinkCategory);
|
||||
|
||||
nodesById.TryAdd(node.NodeId, node);
|
||||
|
||||
// Extract function calls
|
||||
foreach (var call in func.Calls)
|
||||
{
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: func.NodeId,
|
||||
TargetId: call.TargetNodeId,
|
||||
CallKind: call.IsInterface ? CallKind.Virtual : CallKind.Direct,
|
||||
CallSite: $"{relativePath}:{call.Line}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse {File}", goFile);
|
||||
}
|
||||
}
|
||||
|
||||
return BuildSnapshot(request.ScanId, nodesById, edges);
|
||||
}
|
||||
|
||||
private CallGraphSnapshot ConvertToSnapshot(string scanId, GoToolOutput toolOutput)
|
||||
{
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
|
||||
foreach (var node in toolOutput.Nodes ?? [])
|
||||
{
|
||||
var entrypointType = _entrypointClassifier.ClassifyFromFramework(node.Framework, node.FrameworkMethod);
|
||||
var sinkCategory = _sinkMatcher.Match(node.Package ?? "", node.Name ?? "");
|
||||
|
||||
var cgNode = new CallGraphNode(
|
||||
NodeId: node.Id ?? $"go:{node.Package}.{node.Name}",
|
||||
Symbol: $"{node.Package}.{node.Name}",
|
||||
File: node.File ?? "",
|
||||
Line: node.Line,
|
||||
Package: node.Package ?? "",
|
||||
Visibility: node.IsExported ? Visibility.Public : Visibility.Private,
|
||||
IsEntrypoint: entrypointType.HasValue || node.IsEntrypoint,
|
||||
EntrypointType: entrypointType ?? (node.IsEntrypoint ? EntrypointType.HttpHandler : null),
|
||||
IsSink: sinkCategory.HasValue,
|
||||
SinkCategory: sinkCategory);
|
||||
|
||||
nodesById.TryAdd(cgNode.NodeId, cgNode);
|
||||
}
|
||||
|
||||
foreach (var edge in toolOutput.Edges ?? [])
|
||||
{
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: edge.From ?? "",
|
||||
TargetId: edge.To ?? "",
|
||||
CallKind: edge.Kind switch
|
||||
{
|
||||
"interface" => CallKind.Virtual,
|
||||
"dynamic" => CallKind.Delegate,
|
||||
_ => CallKind.Direct
|
||||
},
|
||||
CallSite: edge.Site));
|
||||
}
|
||||
|
||||
return BuildSnapshot(scanId, nodesById, edges);
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildSnapshot(
|
||||
string scanId,
|
||||
Dictionary<string, CallGraphNode> nodesById,
|
||||
HashSet<CallGraphEdge> edges)
|
||||
{
|
||||
var nodes = nodesById.Values
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypointIds = nodes
|
||||
.Where(n => n.IsEntrypoint)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinkIds = nodes
|
||||
.Where(n => n.IsSink)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: nodes,
|
||||
Edges: orderedEdges,
|
||||
EntrypointIds: entrypointIds,
|
||||
SinkIds: sinkIds);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Go call graph extracted: {Nodes} nodes, {Edges} edges, {Entrypoints} entrypoints, {Sinks} sinks",
|
||||
nodes.Length, orderedEdges.Length, entrypointIds.Length, sinkIds.Length);
|
||||
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private static string? FindGoMod(string startPath)
|
||||
{
|
||||
var current = startPath;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
var goModPath = Path.Combine(current, "go.mod");
|
||||
if (File.Exists(goModPath))
|
||||
{
|
||||
return goModPath;
|
||||
}
|
||||
current = Path.GetDirectoryName(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ResolveToolPath()
|
||||
{
|
||||
// Check common locations for the Go tool
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "tools", "stella-callgraph-go", "stella-callgraph-go"),
|
||||
Path.Combine(AppContext.BaseDirectory, "tools", "stella-callgraph-go", "stella-callgraph-go.exe"),
|
||||
"/usr/local/bin/stella-callgraph-go",
|
||||
"stella-callgraph-go"
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0]; // Default path even if not found
|
||||
}
|
||||
|
||||
private static string ExtractPackageName(string content)
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(content, @"^\s*package\s+(\w+)", System.Text.RegularExpressions.RegexOptions.Multiline);
|
||||
return match.Success ? match.Groups[1].Value : "main";
|
||||
}
|
||||
|
||||
private static List<GoFunctionInfo> ExtractFunctions(string content, string file, string packageName)
|
||||
{
|
||||
var functions = new List<GoFunctionInfo>();
|
||||
var funcPattern = new System.Text.RegularExpressions.Regex(
|
||||
@"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?(\w+)\s*\(([^)]*)\)",
|
||||
System.Text.RegularExpressions.RegexOptions.Multiline);
|
||||
|
||||
var lines = content.Split('\n');
|
||||
var lineNumber = 0;
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match match in funcPattern.Matches(content))
|
||||
{
|
||||
var receiverVar = match.Groups[1].Value;
|
||||
var receiverType = match.Groups[2].Value;
|
||||
var funcName = match.Groups[3].Value;
|
||||
var parameters = match.Groups[4].Value;
|
||||
|
||||
// Find line number
|
||||
var charIndex = match.Index;
|
||||
lineNumber = content[..charIndex].Count(c => c == '\n') + 1;
|
||||
|
||||
var isMethod = !string.IsNullOrEmpty(receiverType);
|
||||
var fullName = isMethod ? $"{receiverType}.{funcName}" : funcName;
|
||||
var nodeId = $"go:{packageName}.{fullName}";
|
||||
|
||||
functions.Add(new GoFunctionInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = funcName,
|
||||
Package = packageName,
|
||||
ReceiverType = receiverType,
|
||||
Line = lineNumber,
|
||||
IsExported = char.IsUpper(funcName[0]),
|
||||
Calls = [] // Would need more sophisticated parsing for call extraction
|
||||
});
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Output from stella-callgraph-go tool.
|
||||
/// </summary>
|
||||
internal sealed class GoToolOutput
|
||||
{
|
||||
public string? Module { get; set; }
|
||||
public List<GoToolNode>? Nodes { get; set; }
|
||||
public List<GoToolEdge>? Edges { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GoToolNode
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Package { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Signature { get; set; }
|
||||
public string? File { get; set; }
|
||||
public int Line { get; set; }
|
||||
public bool IsExported { get; set; }
|
||||
public bool IsEntrypoint { get; set; }
|
||||
public string? Framework { get; set; }
|
||||
public string? FrameworkMethod { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GoToolEdge
|
||||
{
|
||||
public string? From { get; set; }
|
||||
public string? To { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public string? Site { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GoFunctionInfo
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Package { get; init; }
|
||||
public string? ReceiverType { get; init; }
|
||||
public int Line { get; init; }
|
||||
public bool IsExported { get; init; }
|
||||
public List<GoCallInfo> Calls { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class GoCallInfo
|
||||
{
|
||||
public required string TargetNodeId { get; init; }
|
||||
public int Line { get; init; }
|
||||
public bool IsInterface { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GoEntrypointClassifier.cs
|
||||
// Sprint: SPRINT_3610_0002_0001_go_callgraph
|
||||
// Description: Classifies Go functions as entrypoints based on framework patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Go;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies Go functions as entrypoints based on framework patterns.
|
||||
/// Supports net/http, Gin, Echo, Fiber, Chi, gRPC, and Cobra.
|
||||
/// </summary>
|
||||
internal sealed class GoEntrypointClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Framework handler patterns mapped to entrypoint types.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, EntrypointType> FrameworkPatterns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// net/http
|
||||
["http.Handler"] = EntrypointType.HttpHandler,
|
||||
["http.HandlerFunc"] = EntrypointType.HttpHandler,
|
||||
["http.HandleFunc"] = EntrypointType.HttpHandler,
|
||||
["http.Handle"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Gin
|
||||
["gin.HandlerFunc"] = EntrypointType.HttpHandler,
|
||||
["gin.Context"] = EntrypointType.HttpHandler,
|
||||
["*gin.Engine.GET"] = EntrypointType.HttpHandler,
|
||||
["*gin.Engine.POST"] = EntrypointType.HttpHandler,
|
||||
["*gin.Engine.PUT"] = EntrypointType.HttpHandler,
|
||||
["*gin.Engine.DELETE"] = EntrypointType.HttpHandler,
|
||||
["*gin.Engine.PATCH"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Echo
|
||||
["echo.HandlerFunc"] = EntrypointType.HttpHandler,
|
||||
["echo.Context"] = EntrypointType.HttpHandler,
|
||||
["*echo.Echo.GET"] = EntrypointType.HttpHandler,
|
||||
["*echo.Echo.POST"] = EntrypointType.HttpHandler,
|
||||
["*echo.Echo.PUT"] = EntrypointType.HttpHandler,
|
||||
["*echo.Echo.DELETE"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Fiber
|
||||
["fiber.Handler"] = EntrypointType.HttpHandler,
|
||||
["*fiber.Ctx"] = EntrypointType.HttpHandler,
|
||||
["*fiber.App.Get"] = EntrypointType.HttpHandler,
|
||||
["*fiber.App.Post"] = EntrypointType.HttpHandler,
|
||||
["*fiber.App.Put"] = EntrypointType.HttpHandler,
|
||||
["*fiber.App.Delete"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Chi
|
||||
["chi.Router"] = EntrypointType.HttpHandler,
|
||||
["chi.Mux"] = EntrypointType.HttpHandler,
|
||||
|
||||
// gorilla/mux
|
||||
["mux.Router"] = EntrypointType.HttpHandler,
|
||||
["*mux.Router.HandleFunc"] = EntrypointType.HttpHandler,
|
||||
|
||||
// gRPC
|
||||
["grpc.UnaryHandler"] = EntrypointType.GrpcMethod,
|
||||
["grpc.StreamHandler"] = EntrypointType.GrpcMethod,
|
||||
["RegisterServer"] = EntrypointType.GrpcMethod,
|
||||
|
||||
// Cobra CLI
|
||||
["cobra.Command"] = EntrypointType.CliCommand,
|
||||
["*cobra.Command.Run"] = EntrypointType.CliCommand,
|
||||
["*cobra.Command.RunE"] = EntrypointType.CliCommand,
|
||||
|
||||
// Cron
|
||||
["cron.Job"] = EntrypointType.ScheduledJob,
|
||||
["*cron.Cron.AddFunc"] = EntrypointType.ScheduledJob,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Package patterns that indicate entrypoints.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, EntrypointType> PackagePatterns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["net/http"] = EntrypointType.HttpHandler,
|
||||
["github.com/gin-gonic/gin"] = EntrypointType.HttpHandler,
|
||||
["github.com/labstack/echo"] = EntrypointType.HttpHandler,
|
||||
["github.com/gofiber/fiber"] = EntrypointType.HttpHandler,
|
||||
["github.com/go-chi/chi"] = EntrypointType.HttpHandler,
|
||||
["github.com/gorilla/mux"] = EntrypointType.HttpHandler,
|
||||
["google.golang.org/grpc"] = EntrypointType.GrpcMethod,
|
||||
["github.com/spf13/cobra"] = EntrypointType.CliCommand,
|
||||
["github.com/robfig/cron"] = EntrypointType.ScheduledJob,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a function based on its info.
|
||||
/// </summary>
|
||||
public EntrypointType? Classify(GoFunctionInfo func)
|
||||
{
|
||||
// Check for main function
|
||||
if (func.Name == "main" && func.Package == "main")
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
|
||||
// Check for init function
|
||||
if (func.Name == "init")
|
||||
{
|
||||
return null; // init functions are not user-facing entrypoints
|
||||
}
|
||||
|
||||
// Check receiver type for common patterns
|
||||
if (!string.IsNullOrEmpty(func.ReceiverType))
|
||||
{
|
||||
// gRPC service methods typically implement interfaces ending in "Server"
|
||||
if (func.ReceiverType.EndsWith("Server", StringComparison.Ordinal) && func.IsExported)
|
||||
{
|
||||
return EntrypointType.GrpcMethod;
|
||||
}
|
||||
|
||||
// HTTP handler methods
|
||||
if (func.ReceiverType.EndsWith("Handler", StringComparison.Ordinal) && func.IsExported)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Controller pattern
|
||||
if (func.ReceiverType.EndsWith("Controller", StringComparison.Ordinal) && func.IsExported)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies based on framework detection from the external tool.
|
||||
/// </summary>
|
||||
public EntrypointType? ClassifyFromFramework(string? framework, string? frameworkMethod)
|
||||
{
|
||||
if (string.IsNullOrEmpty(framework))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check exact framework method match
|
||||
if (!string.IsNullOrEmpty(frameworkMethod) && FrameworkPatterns.TryGetValue(frameworkMethod, out var methodType))
|
||||
{
|
||||
return methodType;
|
||||
}
|
||||
|
||||
// Check package patterns
|
||||
foreach (var (pattern, entrypointType) in PackagePatterns)
|
||||
{
|
||||
if (framework.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return entrypointType;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GoSinkMatcher.cs
|
||||
// Sprint: SPRINT_3610_0002_0001_go_callgraph
|
||||
// Description: Matches Go function calls to known security sinks.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Go;
|
||||
|
||||
/// <summary>
|
||||
/// Matches Go function calls to known security-relevant sinks.
|
||||
/// </summary>
|
||||
public sealed class GoSinkMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of known Go sinks.
|
||||
/// </summary>
|
||||
private static readonly List<GoSinkPattern> SinkPatterns =
|
||||
[
|
||||
// Command injection
|
||||
new("os/exec", "Command", SinkCategory.CmdExec),
|
||||
new("os/exec", "CommandContext", SinkCategory.CmdExec),
|
||||
new("os", "StartProcess", SinkCategory.CmdExec),
|
||||
new("syscall", "Exec", SinkCategory.CmdExec),
|
||||
new("syscall", "ForkExec", SinkCategory.CmdExec),
|
||||
|
||||
// SQL injection
|
||||
new("database/sql", "Query", SinkCategory.SqlRaw),
|
||||
new("database/sql", "QueryRow", SinkCategory.SqlRaw),
|
||||
new("database/sql", "QueryContext", SinkCategory.SqlRaw),
|
||||
new("database/sql", "Exec", SinkCategory.SqlRaw),
|
||||
new("database/sql", "ExecContext", SinkCategory.SqlRaw),
|
||||
new("github.com/jmoiron/sqlx", "Query", SinkCategory.SqlRaw),
|
||||
new("github.com/jmoiron/sqlx", "QueryRow", SinkCategory.SqlRaw),
|
||||
new("github.com/jmoiron/sqlx", "Get", SinkCategory.SqlRaw),
|
||||
new("github.com/jmoiron/sqlx", "Select", SinkCategory.SqlRaw),
|
||||
new("gorm.io/gorm", "Raw", SinkCategory.SqlRaw),
|
||||
new("gorm.io/gorm", "Exec", SinkCategory.SqlRaw),
|
||||
|
||||
// Path traversal
|
||||
new("os", "Open", SinkCategory.PathTraversal),
|
||||
new("os", "OpenFile", SinkCategory.PathTraversal),
|
||||
new("os", "Create", SinkCategory.PathTraversal),
|
||||
new("os", "ReadFile", SinkCategory.PathTraversal),
|
||||
new("os", "WriteFile", SinkCategory.PathTraversal),
|
||||
new("os", "Remove", SinkCategory.PathTraversal),
|
||||
new("os", "RemoveAll", SinkCategory.PathTraversal),
|
||||
new("os", "Rename", SinkCategory.PathTraversal),
|
||||
new("os", "Mkdir", SinkCategory.PathTraversal),
|
||||
new("os", "MkdirAll", SinkCategory.PathTraversal),
|
||||
new("io/ioutil", "ReadFile", SinkCategory.PathTraversal),
|
||||
new("io/ioutil", "WriteFile", SinkCategory.PathTraversal),
|
||||
new("path/filepath", "Join", SinkCategory.PathTraversal),
|
||||
|
||||
// SSRF
|
||||
new("net/http", "Get", SinkCategory.Ssrf),
|
||||
new("net/http", "Post", SinkCategory.Ssrf),
|
||||
new("net/http", "PostForm", SinkCategory.Ssrf),
|
||||
new("net/http", "Head", SinkCategory.Ssrf),
|
||||
new("net/http", "NewRequest", SinkCategory.Ssrf),
|
||||
new("net/http", "NewRequestWithContext", SinkCategory.Ssrf),
|
||||
new("net", "Dial", SinkCategory.Ssrf),
|
||||
new("net", "DialContext", SinkCategory.Ssrf),
|
||||
new("net", "DialTCP", SinkCategory.Ssrf),
|
||||
new("net", "DialUDP", SinkCategory.Ssrf),
|
||||
|
||||
// Deserialization
|
||||
new("encoding/json", "Unmarshal", SinkCategory.UnsafeDeser),
|
||||
new("encoding/json", "NewDecoder", SinkCategory.UnsafeDeser),
|
||||
new("encoding/xml", "Unmarshal", SinkCategory.UnsafeDeser),
|
||||
new("encoding/xml", "NewDecoder", SinkCategory.UnsafeDeser),
|
||||
new("encoding/gob", "Decode", SinkCategory.UnsafeDeser),
|
||||
new("encoding/gob", "NewDecoder", SinkCategory.UnsafeDeser),
|
||||
new("gopkg.in/yaml.v2", "Unmarshal", SinkCategory.UnsafeDeser),
|
||||
new("gopkg.in/yaml.v3", "Unmarshal", SinkCategory.UnsafeDeser),
|
||||
|
||||
// XXE (XML External Entity)
|
||||
new("encoding/xml", "Decoder", SinkCategory.XxeInjection),
|
||||
|
||||
// Template injection
|
||||
new("text/template", "Execute", SinkCategory.TemplateInjection),
|
||||
new("text/template", "ExecuteTemplate", SinkCategory.TemplateInjection),
|
||||
new("html/template", "Execute", SinkCategory.TemplateInjection),
|
||||
new("html/template", "ExecuteTemplate", SinkCategory.TemplateInjection),
|
||||
|
||||
// Log injection
|
||||
new("log", "Print", SinkCategory.LogInjection),
|
||||
new("log", "Printf", SinkCategory.LogInjection),
|
||||
new("log", "Println", SinkCategory.LogInjection),
|
||||
new("log", "Fatal", SinkCategory.LogInjection),
|
||||
new("log", "Fatalf", SinkCategory.LogInjection),
|
||||
new("log", "Panic", SinkCategory.LogInjection),
|
||||
new("log", "Panicf", SinkCategory.LogInjection),
|
||||
new("github.com/sirupsen/logrus", "Info", SinkCategory.LogInjection),
|
||||
new("github.com/sirupsen/logrus", "Warn", SinkCategory.LogInjection),
|
||||
new("github.com/sirupsen/logrus", "Error", SinkCategory.LogInjection),
|
||||
new("go.uber.org/zap", "Info", SinkCategory.LogInjection),
|
||||
new("go.uber.org/zap", "Warn", SinkCategory.LogInjection),
|
||||
new("go.uber.org/zap", "Error", SinkCategory.LogInjection),
|
||||
|
||||
// Reflection
|
||||
new("reflect", "ValueOf", SinkCategory.Reflection),
|
||||
new("reflect", "TypeOf", SinkCategory.Reflection),
|
||||
new("reflect", "New", SinkCategory.Reflection),
|
||||
new("reflect", "MakeFunc", SinkCategory.Reflection),
|
||||
|
||||
// Crypto weaknesses
|
||||
new("crypto/md5", "New", SinkCategory.CryptoWeak),
|
||||
new("crypto/md5", "Sum", SinkCategory.CryptoWeak),
|
||||
new("crypto/sha1", "New", SinkCategory.CryptoWeak),
|
||||
new("crypto/sha1", "Sum", SinkCategory.CryptoWeak),
|
||||
new("crypto/des", "NewCipher", SinkCategory.CryptoWeak),
|
||||
new("crypto/rc4", "NewCipher", SinkCategory.CryptoWeak),
|
||||
new("math/rand", "Int", SinkCategory.CryptoWeak),
|
||||
new("math/rand", "Intn", SinkCategory.CryptoWeak),
|
||||
new("math/rand", "Int63", SinkCategory.CryptoWeak),
|
||||
new("math/rand", "Read", SinkCategory.CryptoWeak),
|
||||
|
||||
// Open redirect
|
||||
new("net/http", "Redirect", SinkCategory.OpenRedirect),
|
||||
|
||||
// Unsafe operations
|
||||
new("unsafe", "Pointer", SinkCategory.CmdExec),
|
||||
new("reflect", "SliceHeader", SinkCategory.CmdExec),
|
||||
new("reflect", "StringHeader", SinkCategory.CmdExec),
|
||||
|
||||
// CGO
|
||||
new("C", "*", SinkCategory.CmdExec),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Matches a function call to a sink category.
|
||||
/// </summary>
|
||||
/// <param name="packagePath">Go package path.</param>
|
||||
/// <param name="funcName">Function name.</param>
|
||||
/// <returns>Sink category if matched; otherwise, null.</returns>
|
||||
public SinkCategory? Match(string packagePath, string funcName)
|
||||
{
|
||||
foreach (var pattern in SinkPatterns)
|
||||
{
|
||||
if (pattern.Matches(packagePath, funcName))
|
||||
{
|
||||
return pattern.Category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for matching Go sinks.
|
||||
/// </summary>
|
||||
internal sealed record GoSinkPattern(string Package, string Function, SinkCategory Category)
|
||||
{
|
||||
public bool Matches(string packagePath, string funcName)
|
||||
{
|
||||
// Check package match (supports partial matching for versioned packages)
|
||||
var packageMatch = string.Equals(packagePath, Package, StringComparison.Ordinal) ||
|
||||
packagePath.EndsWith("/" + Package, StringComparison.Ordinal) ||
|
||||
packagePath.StartsWith(Package + "/", StringComparison.Ordinal);
|
||||
|
||||
if (!packageMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check function match (wildcard support)
|
||||
if (Function == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(funcName, Function, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GoSsaResultParser.cs
|
||||
// Sprint: SPRINT_3610_0002_0001_go_callgraph
|
||||
// Description: Parses JSON output from stella-callgraph-go tool.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Go;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the JSON output from the stella-callgraph-go tool.
|
||||
/// </summary>
|
||||
public static class GoSsaResultParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses the JSON output from stella-callgraph-go.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON string to parse.</param>
|
||||
/// <returns>The parsed call graph result.</returns>
|
||||
public static GoCallGraphResult Parse(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
|
||||
return JsonSerializer.Deserialize<GoCallGraphResult>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse Go call graph result");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the JSON output from stella-callgraph-go asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream containing JSON.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The parsed call graph result.</returns>
|
||||
public static async Task<GoCallGraphResult> ParseAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
return await JsonSerializer.DeserializeAsync<GoCallGraphResult>(stream, JsonOptions, cancellationToken)
|
||||
?? throw new InvalidOperationException("Failed to parse Go call graph result");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Root result from stella-callgraph-go.
|
||||
/// </summary>
|
||||
public sealed record GoCallGraphResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The Go module name.
|
||||
/// </summary>
|
||||
public required string Module { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All function nodes in the call graph.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GoNodeInfo> Nodes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// All call edges in the graph.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GoEdgeInfo> Edges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Detected entrypoints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GoEntrypointInfo> Entrypoints { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A function node from the Go call graph.
|
||||
/// </summary>
|
||||
public sealed record GoNodeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique symbol ID (e.g., go:github.com/example/pkg.Function).
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package path.
|
||||
/// </summary>
|
||||
public required string Package { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function or method name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function signature.
|
||||
/// </summary>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source position.
|
||||
/// </summary>
|
||||
public GoPositionInfo? Position { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Visibility (public/private).
|
||||
/// </summary>
|
||||
public string Visibility { get; init; } = "private";
|
||||
|
||||
/// <summary>
|
||||
/// Detected annotations/patterns.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Annotations { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A call edge from the Go call graph.
|
||||
/// </summary>
|
||||
public sealed record GoEdgeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Source node ID.
|
||||
/// </summary>
|
||||
public required string From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target node ID.
|
||||
/// </summary>
|
||||
public required string To { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call kind (direct, virtual, interface).
|
||||
/// </summary>
|
||||
public string Kind { get; init; } = "direct";
|
||||
|
||||
/// <summary>
|
||||
/// Call site position.
|
||||
/// </summary>
|
||||
public GoPositionInfo? Site { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source code position.
|
||||
/// </summary>
|
||||
public sealed record GoPositionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Source file path (relative to module root).
|
||||
/// </summary>
|
||||
public string? File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number (1-based).
|
||||
/// </summary>
|
||||
public int Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Column number (1-based).
|
||||
/// </summary>
|
||||
public int Column { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An entrypoint from the Go call graph.
|
||||
/// </summary>
|
||||
public sealed record GoEntrypointInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Node ID of the entrypoint.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint type (http_handler, grpc_method, cli_command, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP route if applicable.
|
||||
/// </summary>
|
||||
public string? Route { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP method if applicable.
|
||||
/// </summary>
|
||||
public string? Method { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GoSymbolIdBuilder.cs
|
||||
// Sprint: SPRINT_3610_0002_0001_go_callgraph
|
||||
// Description: Builds stable, deterministic symbol IDs for Go functions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Go;
|
||||
|
||||
/// <summary>
|
||||
/// Builds stable, deterministic symbol IDs for Go functions and methods.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Symbol ID format:
|
||||
/// - Function: go:{package}.{function}
|
||||
/// - Method: go:{package}.{receiver}.{method}
|
||||
/// - External: go:external/{package}.{function}
|
||||
/// </remarks>
|
||||
public static partial class GoSymbolIdBuilder
|
||||
{
|
||||
private const string Prefix = "go:";
|
||||
private const string ExternalPrefix = "go:external/";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a symbol ID for a Go function.
|
||||
/// </summary>
|
||||
/// <param name="packagePath">The Go package import path.</param>
|
||||
/// <param name="functionName">The function name.</param>
|
||||
/// <returns>A stable symbol ID.</returns>
|
||||
public static string BuildFunctionId(string packagePath, string functionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(functionName);
|
||||
|
||||
var normalizedPackage = NormalizePackagePath(packagePath);
|
||||
return $"{Prefix}{normalizedPackage}.{functionName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a symbol ID for a Go method.
|
||||
/// </summary>
|
||||
/// <param name="packagePath">The Go package import path.</param>
|
||||
/// <param name="receiverType">The receiver type name (without pointer).</param>
|
||||
/// <param name="methodName">The method name.</param>
|
||||
/// <returns>A stable symbol ID.</returns>
|
||||
public static string BuildMethodId(string packagePath, string receiverType, string methodName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(receiverType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(methodName);
|
||||
|
||||
var normalizedPackage = NormalizePackagePath(packagePath);
|
||||
var normalizedReceiver = NormalizeReceiverType(receiverType);
|
||||
return $"{Prefix}{normalizedPackage}.{normalizedReceiver}.{methodName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a symbol ID for an external (stdlib or dependency) function.
|
||||
/// </summary>
|
||||
/// <param name="packagePath">The Go package import path.</param>
|
||||
/// <param name="functionName">The function name.</param>
|
||||
/// <returns>A stable symbol ID.</returns>
|
||||
public static string BuildExternalId(string packagePath, string functionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(functionName);
|
||||
|
||||
var normalizedPackage = NormalizePackagePath(packagePath);
|
||||
return $"{ExternalPrefix}{normalizedPackage}.{functionName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a symbol ID into its components.
|
||||
/// </summary>
|
||||
/// <param name="symbolId">The symbol ID to parse.</param>
|
||||
/// <returns>The parsed components, or null if invalid.</returns>
|
||||
public static GoSymbolComponents? Parse(string symbolId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbolId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var isExternal = symbolId.StartsWith(ExternalPrefix, StringComparison.Ordinal);
|
||||
var prefix = isExternal ? ExternalPrefix : Prefix;
|
||||
|
||||
if (!symbolId.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var remainder = symbolId[prefix.Length..];
|
||||
var lastDot = remainder.LastIndexOf('.');
|
||||
if (lastDot < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = remainder[(lastDot + 1)..];
|
||||
var packageOrReceiver = remainder[..lastDot];
|
||||
|
||||
// Check if there's a receiver (another dot before the last)
|
||||
var secondLastDot = packageOrReceiver.LastIndexOf('.');
|
||||
if (secondLastDot > 0 && !packageOrReceiver[..secondLastDot].Contains('/'))
|
||||
{
|
||||
// This might be a method with receiver
|
||||
var potentialReceiver = packageOrReceiver[(secondLastDot + 1)..];
|
||||
if (IsTypeName(potentialReceiver))
|
||||
{
|
||||
return new GoSymbolComponents
|
||||
{
|
||||
PackagePath = packageOrReceiver[..secondLastDot],
|
||||
ReceiverType = potentialReceiver,
|
||||
Name = name,
|
||||
IsExternal = isExternal
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new GoSymbolComponents
|
||||
{
|
||||
PackagePath = packageOrReceiver,
|
||||
ReceiverType = null,
|
||||
Name = name,
|
||||
IsExternal = isExternal
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a symbol ID represents an external (stdlib/dependency) function.
|
||||
/// </summary>
|
||||
public static bool IsExternal(string symbolId)
|
||||
{
|
||||
return symbolId.StartsWith(ExternalPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a symbol ID is from the Go standard library.
|
||||
/// </summary>
|
||||
public static bool IsStdLib(string symbolId)
|
||||
{
|
||||
if (!IsExternal(symbolId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var remainder = symbolId[ExternalPrefix.Length..];
|
||||
// Standard library packages don't have dots in the package name
|
||||
// e.g., go:external/fmt.Println, go:external/os/exec.Command
|
||||
return !remainder.Contains("github.com") &&
|
||||
!remainder.Contains("golang.org/x") &&
|
||||
!remainder.Contains("gopkg.in");
|
||||
}
|
||||
|
||||
private static string NormalizePackagePath(string packagePath)
|
||||
{
|
||||
// Remove version suffix if present (e.g., /v2, /v3)
|
||||
var normalized = VersionSuffixRegex().Replace(packagePath, "");
|
||||
|
||||
// Ensure no double slashes
|
||||
normalized = DoubleSlashRegex().Replace(normalized, "/");
|
||||
|
||||
return normalized.Trim('/');
|
||||
}
|
||||
|
||||
private static string NormalizeReceiverType(string receiverType)
|
||||
{
|
||||
// Remove pointer prefix
|
||||
var normalized = receiverType.TrimStart('*');
|
||||
|
||||
// Remove package prefix if present (e.g., pkg.Type -> Type)
|
||||
var lastDot = normalized.LastIndexOf('.');
|
||||
if (lastDot >= 0)
|
||||
{
|
||||
normalized = normalized[(lastDot + 1)..];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsTypeName(string name)
|
||||
{
|
||||
// Type names in Go start with uppercase letter
|
||||
return name.Length > 0 && char.IsUpper(name[0]);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"/v\d+$")]
|
||||
private static partial Regex VersionSuffixRegex();
|
||||
|
||||
[GeneratedRegex(@"//+")]
|
||||
private static partial Regex DoubleSlashRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed components of a Go symbol ID.
|
||||
/// </summary>
|
||||
public sealed record GoSymbolComponents
|
||||
{
|
||||
/// <summary>
|
||||
/// The Go package import path.
|
||||
/// </summary>
|
||||
public required string PackagePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The receiver type name if this is a method, null for functions.
|
||||
/// </summary>
|
||||
public string? ReceiverType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The function or method name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is an external (stdlib/dependency) symbol.
|
||||
/// </summary>
|
||||
public bool IsExternal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a method (has a receiver).
|
||||
/// </summary>
|
||||
public bool IsMethod => ReceiverType is not null;
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaBytecodeAnalyzer.cs
|
||||
// Sprint: SPRINT_3610_0001_0001_java_callgraph
|
||||
// Description: Pure .NET Java bytecode parser for class file analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Java;
|
||||
|
||||
/// <summary>
|
||||
/// Parses Java class files to extract method signatures and call relationships.
|
||||
/// Implements pure .NET bytecode parsing without external dependencies.
|
||||
/// </summary>
|
||||
public sealed class JavaBytecodeAnalyzer
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private const uint ClassFileMagic = 0xCAFEBABE;
|
||||
|
||||
public JavaBytecodeAnalyzer(ILogger logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Java class file and extracts method and call information.
|
||||
/// </summary>
|
||||
public JavaClassInfo? ParseClass(byte[] classData)
|
||||
{
|
||||
if (classData is null || classData.Length < 10)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var reader = new ByteReader(classData);
|
||||
|
||||
// Magic number
|
||||
var magic = reader.ReadUInt32BE();
|
||||
if (magic != ClassFileMagic)
|
||||
{
|
||||
_logger.LogDebug("Invalid class file magic: {Magic:X8}", magic);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Version
|
||||
var minorVersion = reader.ReadUInt16BE();
|
||||
var majorVersion = reader.ReadUInt16BE();
|
||||
|
||||
// Constant pool
|
||||
var constantPool = ReadConstantPool(reader);
|
||||
|
||||
// Access flags
|
||||
var accessFlags = (JavaAccessFlags)reader.ReadUInt16BE();
|
||||
|
||||
// This class
|
||||
var thisClassIndex = reader.ReadUInt16BE();
|
||||
var className = ResolveClassName(constantPool, thisClassIndex);
|
||||
|
||||
// Super class
|
||||
var superClassIndex = reader.ReadUInt16BE();
|
||||
var superClassName = superClassIndex > 0 ? ResolveClassName(constantPool, superClassIndex) : null;
|
||||
|
||||
// Interfaces
|
||||
var interfaceCount = reader.ReadUInt16BE();
|
||||
var interfaces = new List<string>();
|
||||
for (int i = 0; i < interfaceCount; i++)
|
||||
{
|
||||
var ifIndex = reader.ReadUInt16BE();
|
||||
var ifName = ResolveClassName(constantPool, ifIndex);
|
||||
if (ifName is not null)
|
||||
interfaces.Add(ifName);
|
||||
}
|
||||
|
||||
// Fields (skip for now)
|
||||
var fieldCount = reader.ReadUInt16BE();
|
||||
for (int i = 0; i < fieldCount; i++)
|
||||
{
|
||||
SkipFieldOrMethod(reader);
|
||||
}
|
||||
|
||||
// Methods
|
||||
var methodCount = reader.ReadUInt16BE();
|
||||
var methods = new List<JavaMethodInfo>();
|
||||
|
||||
for (int i = 0; i < methodCount; i++)
|
||||
{
|
||||
var method = ReadMethod(reader, constantPool, className!);
|
||||
if (method is not null)
|
||||
methods.Add(method);
|
||||
}
|
||||
|
||||
// Class attributes (look for SourceFile)
|
||||
string? sourceFile = null;
|
||||
var attrCount = reader.ReadUInt16BE();
|
||||
for (int i = 0; i < attrCount; i++)
|
||||
{
|
||||
var nameIndex = reader.ReadUInt16BE();
|
||||
var length = reader.ReadUInt32BE();
|
||||
var attrName = ResolveUtf8(constantPool, nameIndex);
|
||||
|
||||
if (attrName == "SourceFile" && length >= 2)
|
||||
{
|
||||
var sfIndex = reader.ReadUInt16BE();
|
||||
sourceFile = ResolveUtf8(constantPool, sfIndex);
|
||||
length -= 2;
|
||||
}
|
||||
|
||||
if (length > 0)
|
||||
reader.Skip((int)length);
|
||||
}
|
||||
|
||||
return new JavaClassInfo
|
||||
{
|
||||
ClassName = className ?? "unknown",
|
||||
SuperClassName = superClassName,
|
||||
Interfaces = interfaces,
|
||||
AccessFlags = accessFlags,
|
||||
MajorVersion = majorVersion,
|
||||
MinorVersion = minorVersion,
|
||||
SourceFile = sourceFile,
|
||||
Methods = methods
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse class file");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<ConstantPoolEntry> ReadConstantPool(ByteReader reader)
|
||||
{
|
||||
var count = reader.ReadUInt16BE();
|
||||
var pool = new List<ConstantPoolEntry>(count) { new ConstantPoolEntry() }; // Index 0 is unused
|
||||
|
||||
for (int i = 1; i < count; i++)
|
||||
{
|
||||
var tag = (ConstantTag)reader.ReadByte();
|
||||
var entry = new ConstantPoolEntry { Tag = tag };
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case ConstantTag.Utf8:
|
||||
var length = reader.ReadUInt16BE();
|
||||
entry.StringValue = Encoding.UTF8.GetString(reader.ReadBytes(length));
|
||||
break;
|
||||
|
||||
case ConstantTag.Integer:
|
||||
case ConstantTag.Float:
|
||||
entry.IntValue = reader.ReadInt32BE();
|
||||
break;
|
||||
|
||||
case ConstantTag.Long:
|
||||
case ConstantTag.Double:
|
||||
entry.LongValue = reader.ReadInt64BE();
|
||||
pool.Add(entry);
|
||||
pool.Add(new ConstantPoolEntry()); // Long/Double take two slots
|
||||
i++;
|
||||
continue;
|
||||
|
||||
case ConstantTag.Class:
|
||||
case ConstantTag.String:
|
||||
case ConstantTag.MethodType:
|
||||
case ConstantTag.Module:
|
||||
case ConstantTag.Package:
|
||||
entry.Index1 = reader.ReadUInt16BE();
|
||||
break;
|
||||
|
||||
case ConstantTag.Fieldref:
|
||||
case ConstantTag.Methodref:
|
||||
case ConstantTag.InterfaceMethodref:
|
||||
case ConstantTag.NameAndType:
|
||||
case ConstantTag.Dynamic:
|
||||
case ConstantTag.InvokeDynamic:
|
||||
entry.Index1 = reader.ReadUInt16BE();
|
||||
entry.Index2 = reader.ReadUInt16BE();
|
||||
break;
|
||||
|
||||
case ConstantTag.MethodHandle:
|
||||
entry.RefKind = reader.ReadByte();
|
||||
entry.Index1 = reader.ReadUInt16BE();
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogDebug("Unknown constant pool tag: {Tag}", tag);
|
||||
break;
|
||||
}
|
||||
|
||||
pool.Add(entry);
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
private JavaMethodInfo? ReadMethod(ByteReader reader, List<ConstantPoolEntry> constantPool, string declaringClass)
|
||||
{
|
||||
var accessFlags = (JavaAccessFlags)reader.ReadUInt16BE();
|
||||
var nameIndex = reader.ReadUInt16BE();
|
||||
var descriptorIndex = reader.ReadUInt16BE();
|
||||
|
||||
var methodName = ResolveUtf8(constantPool, nameIndex);
|
||||
var descriptor = ResolveUtf8(constantPool, descriptorIndex);
|
||||
|
||||
if (methodName is null || descriptor is null)
|
||||
{
|
||||
SkipAttributes(reader);
|
||||
return null;
|
||||
}
|
||||
|
||||
var calls = new List<JavaMethodCall>();
|
||||
var annotations = new List<string>();
|
||||
var lineNumber = 0;
|
||||
|
||||
// Read attributes
|
||||
var attrCount = reader.ReadUInt16BE();
|
||||
for (int i = 0; i < attrCount; i++)
|
||||
{
|
||||
var attrNameIndex = reader.ReadUInt16BE();
|
||||
var attrLength = reader.ReadUInt32BE();
|
||||
var attrName = ResolveUtf8(constantPool, attrNameIndex);
|
||||
|
||||
if (attrName == "Code" && attrLength > 0)
|
||||
{
|
||||
var codeStart = reader.Position;
|
||||
var maxStack = reader.ReadUInt16BE();
|
||||
var maxLocals = reader.ReadUInt16BE();
|
||||
var codeLength = reader.ReadUInt32BE();
|
||||
|
||||
// Parse bytecode for invocations
|
||||
var codeEnd = reader.Position + (int)codeLength;
|
||||
while (reader.Position < codeEnd)
|
||||
{
|
||||
var opcode = (JavaOpcode)reader.ReadByte();
|
||||
|
||||
switch (opcode)
|
||||
{
|
||||
case JavaOpcode.InvokeVirtual:
|
||||
case JavaOpcode.InvokeSpecial:
|
||||
case JavaOpcode.InvokeStatic:
|
||||
case JavaOpcode.InvokeInterface:
|
||||
var methodRefIndex = reader.ReadUInt16BE();
|
||||
var call = ResolveMethodRef(constantPool, methodRefIndex, opcode);
|
||||
if (call is not null)
|
||||
calls.Add(call);
|
||||
|
||||
if (opcode == JavaOpcode.InvokeInterface)
|
||||
reader.Skip(2); // count + 0
|
||||
break;
|
||||
|
||||
case JavaOpcode.InvokeDynamic:
|
||||
var indyIndex = reader.ReadUInt16BE();
|
||||
reader.Skip(2); // two zero bytes
|
||||
var indyCall = ResolveInvokeDynamic(constantPool, indyIndex);
|
||||
if (indyCall is not null)
|
||||
calls.Add(indyCall);
|
||||
break;
|
||||
|
||||
default:
|
||||
SkipOpcodeOperands(reader, opcode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Exception table
|
||||
var exceptionTableLength = reader.ReadUInt16BE();
|
||||
reader.Skip(exceptionTableLength * 8);
|
||||
|
||||
// Code attributes (LineNumberTable, etc.)
|
||||
var codeAttrCount = reader.ReadUInt16BE();
|
||||
for (int j = 0; j < codeAttrCount; j++)
|
||||
{
|
||||
var caNameIndex = reader.ReadUInt16BE();
|
||||
var caLength = reader.ReadUInt32BE();
|
||||
var caName = ResolveUtf8(constantPool, caNameIndex);
|
||||
|
||||
if (caName == "LineNumberTable" && caLength >= 2)
|
||||
{
|
||||
var tableLength = reader.ReadUInt16BE();
|
||||
if (tableLength > 0)
|
||||
{
|
||||
reader.Skip(2); // start_pc
|
||||
lineNumber = reader.ReadUInt16BE();
|
||||
reader.Skip((tableLength - 1) * 4);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Skip((int)caLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (attrName == "RuntimeVisibleAnnotations" || attrName == "RuntimeInvisibleAnnotations")
|
||||
{
|
||||
var numAnnotations = reader.ReadUInt16BE();
|
||||
for (int j = 0; j < numAnnotations; j++)
|
||||
{
|
||||
var typeIndex = reader.ReadUInt16BE();
|
||||
var annotationType = ResolveUtf8(constantPool, typeIndex);
|
||||
if (annotationType is not null)
|
||||
annotations.Add(ParseAnnotationTypeName(annotationType));
|
||||
|
||||
SkipAnnotationElements(reader);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Skip((int)attrLength);
|
||||
}
|
||||
}
|
||||
|
||||
return new JavaMethodInfo
|
||||
{
|
||||
Name = methodName,
|
||||
Descriptor = descriptor,
|
||||
AccessFlags = accessFlags,
|
||||
LineNumber = lineNumber,
|
||||
Calls = calls,
|
||||
Annotations = annotations
|
||||
};
|
||||
}
|
||||
|
||||
private static void SkipFieldOrMethod(ByteReader reader)
|
||||
{
|
||||
reader.Skip(6); // access_flags, name_index, descriptor_index
|
||||
SkipAttributes(reader);
|
||||
}
|
||||
|
||||
private static void SkipAttributes(ByteReader reader)
|
||||
{
|
||||
var count = reader.ReadUInt16BE();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
reader.Skip(2); // name_index
|
||||
var length = reader.ReadUInt32BE();
|
||||
reader.Skip((int)length);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SkipAnnotationElements(ByteReader reader)
|
||||
{
|
||||
var numPairs = reader.ReadUInt16BE();
|
||||
for (int i = 0; i < numPairs; i++)
|
||||
{
|
||||
reader.Skip(2); // element_name_index
|
||||
SkipElementValue(reader);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SkipElementValue(ByteReader reader)
|
||||
{
|
||||
var tag = (char)reader.ReadByte();
|
||||
switch (tag)
|
||||
{
|
||||
case 'B': case 'C': case 'D': case 'F': case 'I':
|
||||
case 'J': case 'S': case 'Z': case 's':
|
||||
reader.Skip(2);
|
||||
break;
|
||||
case 'e':
|
||||
reader.Skip(4);
|
||||
break;
|
||||
case 'c':
|
||||
reader.Skip(2);
|
||||
break;
|
||||
case '@':
|
||||
reader.Skip(2);
|
||||
SkipAnnotationElements(reader);
|
||||
break;
|
||||
case '[':
|
||||
var numValues = reader.ReadUInt16BE();
|
||||
for (int i = 0; i < numValues; i++)
|
||||
SkipElementValue(reader);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SkipOpcodeOperands(ByteReader reader, JavaOpcode opcode)
|
||||
{
|
||||
// Skip operands based on opcode
|
||||
var size = GetOpcodeOperandSize(opcode);
|
||||
if (size > 0)
|
||||
reader.Skip(size);
|
||||
}
|
||||
|
||||
private static int GetOpcodeOperandSize(JavaOpcode opcode)
|
||||
{
|
||||
return opcode switch
|
||||
{
|
||||
>= JavaOpcode.Bipush and <= JavaOpcode.Aload => 1,
|
||||
>= JavaOpcode.Iload_0 and <= JavaOpcode.Aload_3 => 0,
|
||||
>= JavaOpcode.Iaload and <= JavaOpcode.Saload => 0,
|
||||
>= JavaOpcode.Istore_0 and <= JavaOpcode.Astore_3 => 0,
|
||||
>= JavaOpcode.Iastore and <= JavaOpcode.Sastore => 0,
|
||||
>= JavaOpcode.Pop and <= JavaOpcode.Swap => 0,
|
||||
>= JavaOpcode.Iadd and <= JavaOpcode.Lxor => 0,
|
||||
JavaOpcode.Iinc => 2,
|
||||
>= JavaOpcode.I2l and <= JavaOpcode.Dcmpg => 0,
|
||||
>= JavaOpcode.Ifeq and <= JavaOpcode.Jsr => 2,
|
||||
JavaOpcode.Ret => 1,
|
||||
JavaOpcode.Tableswitch or JavaOpcode.Lookupswitch => -1, // Variable, handled separately
|
||||
>= JavaOpcode.Ireturn and <= JavaOpcode.Return => 0,
|
||||
JavaOpcode.Getstatic or JavaOpcode.Putstatic or
|
||||
JavaOpcode.Getfield or JavaOpcode.Putfield => 2,
|
||||
JavaOpcode.New or JavaOpcode.Anewarray or
|
||||
JavaOpcode.Checkcast or JavaOpcode.Instanceof => 2,
|
||||
JavaOpcode.Newarray => 1,
|
||||
JavaOpcode.Arraylength => 0,
|
||||
JavaOpcode.Athrow => 0,
|
||||
JavaOpcode.Monitorenter or JavaOpcode.Monitorexit => 0,
|
||||
JavaOpcode.Wide => -1, // Variable
|
||||
JavaOpcode.Multianewarray => 3,
|
||||
JavaOpcode.Ifnull or JavaOpcode.Ifnonnull => 2,
|
||||
JavaOpcode.Goto_w or JavaOpcode.Jsr_w => 4,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private string? ResolveClassName(List<ConstantPoolEntry> pool, ushort classIndex)
|
||||
{
|
||||
if (classIndex == 0 || classIndex >= pool.Count)
|
||||
return null;
|
||||
|
||||
var classEntry = pool[classIndex];
|
||||
if (classEntry.Tag != ConstantTag.Class)
|
||||
return null;
|
||||
|
||||
return ResolveUtf8(pool, classEntry.Index1)?.Replace('/', '.');
|
||||
}
|
||||
|
||||
private static string? ResolveUtf8(List<ConstantPoolEntry> pool, ushort index)
|
||||
{
|
||||
if (index == 0 || index >= pool.Count)
|
||||
return null;
|
||||
|
||||
var entry = pool[index];
|
||||
return entry.Tag == ConstantTag.Utf8 ? entry.StringValue : null;
|
||||
}
|
||||
|
||||
private JavaMethodCall? ResolveMethodRef(List<ConstantPoolEntry> pool, ushort index, JavaOpcode opcode)
|
||||
{
|
||||
if (index == 0 || index >= pool.Count)
|
||||
return null;
|
||||
|
||||
var entry = pool[index];
|
||||
if (entry.Tag != ConstantTag.Methodref && entry.Tag != ConstantTag.InterfaceMethodref)
|
||||
return null;
|
||||
|
||||
var className = ResolveClassName(pool, entry.Index1);
|
||||
if (className is null)
|
||||
return null;
|
||||
|
||||
if (entry.Index2 >= pool.Count)
|
||||
return null;
|
||||
|
||||
var natEntry = pool[entry.Index2];
|
||||
if (natEntry.Tag != ConstantTag.NameAndType)
|
||||
return null;
|
||||
|
||||
var methodName = ResolveUtf8(pool, natEntry.Index1);
|
||||
var descriptor = ResolveUtf8(pool, natEntry.Index2);
|
||||
|
||||
if (methodName is null || descriptor is null)
|
||||
return null;
|
||||
|
||||
return new JavaMethodCall
|
||||
{
|
||||
TargetClass = className,
|
||||
MethodName = methodName,
|
||||
Descriptor = descriptor,
|
||||
Opcode = opcode
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an InvokeDynamic constant pool entry.
|
||||
/// InvokeDynamic is used for lambda expressions, method references, and string concatenation.
|
||||
/// </summary>
|
||||
private JavaMethodCall? ResolveInvokeDynamic(List<ConstantPoolEntry> pool, ushort index)
|
||||
{
|
||||
if (index == 0 || index >= pool.Count)
|
||||
return null;
|
||||
|
||||
var entry = pool[index];
|
||||
if (entry.Tag != ConstantTag.InvokeDynamic)
|
||||
return null;
|
||||
|
||||
// Index1 is bootstrap method attr index (we skip this for now)
|
||||
// Index2 is name_and_type index
|
||||
if (entry.Index2 >= pool.Count)
|
||||
return null;
|
||||
|
||||
var natEntry = pool[entry.Index2];
|
||||
if (natEntry.Tag != ConstantTag.NameAndType)
|
||||
return null;
|
||||
|
||||
var methodName = ResolveUtf8(pool, natEntry.Index1);
|
||||
var descriptor = ResolveUtf8(pool, natEntry.Index2);
|
||||
|
||||
if (methodName is null || descriptor is null)
|
||||
return null;
|
||||
|
||||
// For lambdas, the method name is often synthetic (e.g., "lambda$methodName$0")
|
||||
// We try to extract the target functional interface from the descriptor
|
||||
var targetInterface = ExtractFunctionalInterface(descriptor);
|
||||
|
||||
return new JavaMethodCall
|
||||
{
|
||||
TargetClass = targetInterface ?? "java.lang.invoke.LambdaMetafactory",
|
||||
MethodName = methodName,
|
||||
Descriptor = descriptor,
|
||||
Opcode = JavaOpcode.InvokeDynamic,
|
||||
IsLambda = methodName.StartsWith("lambda$") || methodName.Contains("$lambda$"),
|
||||
IsDynamic = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the functional interface from an invokedynamic descriptor.
|
||||
/// </summary>
|
||||
private static string? ExtractFunctionalInterface(string descriptor)
|
||||
{
|
||||
// Descriptor format: (captured_args)Lfunctional/Interface;
|
||||
// Example: ()Ljava/util/function/Consumer; -> java.util.function.Consumer
|
||||
var lastParen = descriptor.LastIndexOf(')');
|
||||
if (lastParen < 0 || lastParen >= descriptor.Length - 1)
|
||||
return null;
|
||||
|
||||
var returnType = descriptor[(lastParen + 1)..];
|
||||
if (returnType.StartsWith("L") && returnType.EndsWith(";"))
|
||||
{
|
||||
return returnType[1..^1].Replace('/', '.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ParseAnnotationTypeName(string descriptor)
|
||||
{
|
||||
// Convert Lcom/example/Annotation; to com.example.Annotation
|
||||
if (descriptor.StartsWith("L") && descriptor.EndsWith(";"))
|
||||
{
|
||||
return descriptor[1..^1].Replace('/', '.');
|
||||
}
|
||||
return descriptor.Replace('/', '.');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for reading big-endian binary data.
|
||||
/// </summary>
|
||||
internal ref struct ByteReader
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _data;
|
||||
private int _position;
|
||||
|
||||
public ByteReader(byte[] data)
|
||||
{
|
||||
_data = data;
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
public int Position => _position;
|
||||
|
||||
public byte ReadByte() => _data[_position++];
|
||||
|
||||
public ushort ReadUInt16BE()
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt16BigEndian(_data[_position..]);
|
||||
_position += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
public uint ReadUInt32BE()
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt32BigEndian(_data[_position..]);
|
||||
_position += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
public int ReadInt32BE()
|
||||
{
|
||||
var value = BinaryPrimitives.ReadInt32BigEndian(_data[_position..]);
|
||||
_position += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
public long ReadInt64BE()
|
||||
{
|
||||
var value = BinaryPrimitives.ReadInt64BigEndian(_data[_position..]);
|
||||
_position += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
public byte[] ReadBytes(int count)
|
||||
{
|
||||
var bytes = _data.Slice(_position, count).ToArray();
|
||||
_position += count;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public void Skip(int count) => _position += count;
|
||||
}
|
||||
|
||||
internal enum ConstantTag : byte
|
||||
{
|
||||
Utf8 = 1,
|
||||
Integer = 3,
|
||||
Float = 4,
|
||||
Long = 5,
|
||||
Double = 6,
|
||||
Class = 7,
|
||||
String = 8,
|
||||
Fieldref = 9,
|
||||
Methodref = 10,
|
||||
InterfaceMethodref = 11,
|
||||
NameAndType = 12,
|
||||
MethodHandle = 15,
|
||||
MethodType = 16,
|
||||
Dynamic = 17,
|
||||
InvokeDynamic = 18,
|
||||
Module = 19,
|
||||
Package = 20
|
||||
}
|
||||
|
||||
internal struct ConstantPoolEntry
|
||||
{
|
||||
public ConstantTag Tag;
|
||||
public string? StringValue;
|
||||
public int IntValue;
|
||||
public long LongValue;
|
||||
public ushort Index1;
|
||||
public ushort Index2;
|
||||
public byte RefKind;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaCallGraphExtractor.cs
|
||||
// Sprint: SPRINT_3610_0001_0001_java_callgraph
|
||||
// Description: Java bytecode call graph extractor for reachability analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Java;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call graphs from Java bytecode (JARs, WARs, class files).
|
||||
/// Uses pure .NET bytecode parsing for deterministic, offline-friendly analysis.
|
||||
/// </summary>
|
||||
public sealed class JavaCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly ILogger<JavaCallGraphExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JavaBytecodeAnalyzer _bytecodeAnalyzer;
|
||||
private readonly JavaEntrypointClassifier _entrypointClassifier;
|
||||
private readonly JavaSinkMatcher _sinkMatcher;
|
||||
|
||||
public JavaCallGraphExtractor(
|
||||
ILogger<JavaCallGraphExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_bytecodeAnalyzer = new JavaBytecodeAnalyzer(logger);
|
||||
_entrypointClassifier = new JavaEntrypointClassifier();
|
||||
_sinkMatcher = new JavaSinkMatcher();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Language => "java";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!string.Equals(request.Language, Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"Expected language '{Language}', got '{request.Language}'.", nameof(request));
|
||||
}
|
||||
|
||||
var targetPath = Path.GetFullPath(request.TargetPath);
|
||||
if (!File.Exists(targetPath) && !Directory.Exists(targetPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Target path not found: {targetPath}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting Java call graph extraction for {Path}", targetPath);
|
||||
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
var classInfos = new List<JavaClassInfo>();
|
||||
|
||||
// Phase 1: Collect all class files
|
||||
var classFiles = await CollectClassFilesAsync(targetPath, cancellationToken);
|
||||
_logger.LogDebug("Found {Count} class files to analyze", classFiles.Count);
|
||||
|
||||
// Phase 2: Parse all classes to build method index
|
||||
foreach (var (archivePath, entryName, classData) in classFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var classInfo = _bytecodeAnalyzer.ParseClass(classData);
|
||||
if (classInfo is not null)
|
||||
{
|
||||
classInfo = classInfo with { SourceArchive = archivePath, SourceEntry = entryName };
|
||||
classInfos.Add(classInfo);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse class from {Archive}!{Entry}", archivePath, entryName);
|
||||
}
|
||||
}
|
||||
|
||||
// Build index of all package-internal classes
|
||||
var packageClasses = classInfos
|
||||
.Select(c => c.ClassName)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Phase 3: Build nodes and edges
|
||||
foreach (var classInfo in classInfos)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var method in classInfo.Methods)
|
||||
{
|
||||
var nodeId = BuildNodeId(classInfo.ClassName, method.Name, method.Descriptor);
|
||||
var entrypointType = _entrypointClassifier.Classify(classInfo, method);
|
||||
var sinkCategory = _sinkMatcher.Match(classInfo.ClassName, method.Name, method.Descriptor);
|
||||
|
||||
var node = new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: $"{classInfo.ClassName}.{method.Name}",
|
||||
File: classInfo.SourceFile ?? classInfo.SourceEntry ?? string.Empty,
|
||||
Line: method.LineNumber,
|
||||
Package: ExtractPackage(classInfo.ClassName),
|
||||
Visibility: MapVisibility(method.AccessFlags),
|
||||
IsEntrypoint: entrypointType.HasValue,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: sinkCategory.HasValue,
|
||||
SinkCategory: sinkCategory);
|
||||
|
||||
nodesById.TryAdd(nodeId, node);
|
||||
|
||||
// Add edges for method invocations
|
||||
foreach (var call in method.Calls)
|
||||
{
|
||||
// Only include edges to internal methods
|
||||
if (!packageClasses.Contains(call.TargetClass))
|
||||
{
|
||||
// Check if it's a known sink
|
||||
var targetSink = _sinkMatcher.Match(call.TargetClass, call.MethodName, call.Descriptor);
|
||||
if (targetSink.HasValue)
|
||||
{
|
||||
var sinkNodeId = BuildNodeId(call.TargetClass, call.MethodName, call.Descriptor);
|
||||
nodesById.TryAdd(sinkNodeId, new CallGraphNode(
|
||||
NodeId: sinkNodeId,
|
||||
Symbol: $"{call.TargetClass}.{call.MethodName}",
|
||||
File: string.Empty,
|
||||
Line: 0,
|
||||
Package: ExtractPackage(call.TargetClass),
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: true,
|
||||
SinkCategory: targetSink));
|
||||
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: nodeId,
|
||||
TargetId: sinkNodeId,
|
||||
CallKind: MapCallKind(call.Opcode),
|
||||
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetNodeId = BuildNodeId(call.TargetClass, call.MethodName, call.Descriptor);
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: nodeId,
|
||||
TargetId: targetNodeId,
|
||||
CallKind: MapCallKind(call.Opcode),
|
||||
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final snapshot
|
||||
var nodes = nodesById.Values
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypointIds = nodes
|
||||
.Where(n => n.IsEntrypoint)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinkIds = nodes
|
||||
.Where(n => n.IsSink)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.CallKind.ToString(), StringComparer.Ordinal)
|
||||
.ThenBy(e => e.CallSite ?? string.Empty, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: request.ScanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: nodes,
|
||||
Edges: orderedEdges,
|
||||
EntrypointIds: entrypointIds,
|
||||
SinkIds: sinkIds);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Java call graph extracted: {Nodes} nodes, {Edges} edges, {Entrypoints} entrypoints, {Sinks} sinks",
|
||||
nodes.Length, orderedEdges.Length, entrypointIds.Length, sinkIds.Length);
|
||||
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private async Task<List<(string ArchivePath, string EntryName, byte[] Data)>> CollectClassFilesAsync(
|
||||
string path,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = new List<(string, string, byte[])>();
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
if (path.EndsWith(".jar", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".war", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".ear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ExtractFromArchiveAsync(path, result, ct);
|
||||
}
|
||||
else if (path.EndsWith(".class", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var data = await File.ReadAllBytesAsync(path, ct);
|
||||
result.Add((path, Path.GetFileName(path), data));
|
||||
}
|
||||
}
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
// Find all JARs and class files
|
||||
foreach (var jar in Directory.EnumerateFiles(path, "*.jar", SearchOption.AllDirectories).OrderBy(p => p))
|
||||
{
|
||||
await ExtractFromArchiveAsync(jar, result, ct);
|
||||
}
|
||||
|
||||
foreach (var war in Directory.EnumerateFiles(path, "*.war", SearchOption.AllDirectories).OrderBy(p => p))
|
||||
{
|
||||
await ExtractFromArchiveAsync(war, result, ct);
|
||||
}
|
||||
|
||||
foreach (var classFile in Directory.EnumerateFiles(path, "*.class", SearchOption.AllDirectories).OrderBy(p => p))
|
||||
{
|
||||
var data = await File.ReadAllBytesAsync(classFile, ct);
|
||||
var relativePath = Path.GetRelativePath(path, classFile);
|
||||
result.Add((path, relativePath, data));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task ExtractFromArchiveAsync(
|
||||
string archivePath,
|
||||
List<(string, string, byte[])> result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(archivePath);
|
||||
foreach (var entry in archive.Entries.OrderBy(e => e.FullName))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!entry.FullName.EndsWith(".class", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// Skip module-info and package-info
|
||||
if (entry.Name.Equals("module-info.class", StringComparison.OrdinalIgnoreCase) ||
|
||||
entry.Name.Equals("package-info.class", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
using var stream = entry.Open();
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms, ct);
|
||||
result.Add((archivePath, entry.FullName, ms.ToArray()));
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildNodeId(string className, string methodName, string descriptor)
|
||||
{
|
||||
// Format: java:{package}.{class}.{method}({paramTypes}){returnType}
|
||||
return $"java:{className}.{methodName}{descriptor}";
|
||||
}
|
||||
|
||||
private static string ExtractPackage(string className)
|
||||
{
|
||||
var lastDot = className.LastIndexOf('.');
|
||||
return lastDot > 0 ? className[..lastDot] : string.Empty;
|
||||
}
|
||||
|
||||
private static Visibility MapVisibility(JavaAccessFlags flags)
|
||||
{
|
||||
if ((flags & JavaAccessFlags.Public) != 0) return Visibility.Public;
|
||||
if ((flags & JavaAccessFlags.Protected) != 0) return Visibility.Protected;
|
||||
if ((flags & JavaAccessFlags.Private) != 0) return Visibility.Private;
|
||||
return Visibility.Internal; // package-private
|
||||
}
|
||||
|
||||
private static CallKind MapCallKind(JavaOpcode opcode)
|
||||
{
|
||||
return opcode switch
|
||||
{
|
||||
JavaOpcode.InvokeVirtual => CallKind.Virtual,
|
||||
JavaOpcode.InvokeInterface => CallKind.Virtual,
|
||||
JavaOpcode.InvokeStatic => CallKind.Direct,
|
||||
JavaOpcode.InvokeSpecial => CallKind.Direct,
|
||||
JavaOpcode.InvokeDynamic => CallKind.Delegate,
|
||||
_ => CallKind.Direct
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaEntrypointClassifier.cs
|
||||
// Sprint: SPRINT_3610_0001_0001_java_callgraph
|
||||
// Description: Classifies Java methods as entrypoints based on framework annotations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Java;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies Java methods as entrypoints based on framework annotations.
|
||||
/// Supports Spring Boot, JAX-RS, Micronaut, Quarkus, and other common frameworks.
|
||||
/// </summary>
|
||||
public sealed class JavaEntrypointClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Mapping of annotation types to entrypoint types.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, EntrypointType> AnnotationMap = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Spring MVC / Spring Boot HTTP handlers
|
||||
["org.springframework.web.bind.annotation.RequestMapping"] = EntrypointType.HttpHandler,
|
||||
["org.springframework.web.bind.annotation.GetMapping"] = EntrypointType.HttpHandler,
|
||||
["org.springframework.web.bind.annotation.PostMapping"] = EntrypointType.HttpHandler,
|
||||
["org.springframework.web.bind.annotation.PutMapping"] = EntrypointType.HttpHandler,
|
||||
["org.springframework.web.bind.annotation.DeleteMapping"] = EntrypointType.HttpHandler,
|
||||
["org.springframework.web.bind.annotation.PatchMapping"] = EntrypointType.HttpHandler,
|
||||
|
||||
// JAX-RS HTTP handlers
|
||||
["javax.ws.rs.GET"] = EntrypointType.HttpHandler,
|
||||
["javax.ws.rs.POST"] = EntrypointType.HttpHandler,
|
||||
["javax.ws.rs.PUT"] = EntrypointType.HttpHandler,
|
||||
["javax.ws.rs.DELETE"] = EntrypointType.HttpHandler,
|
||||
["javax.ws.rs.PATCH"] = EntrypointType.HttpHandler,
|
||||
["javax.ws.rs.HEAD"] = EntrypointType.HttpHandler,
|
||||
["javax.ws.rs.OPTIONS"] = EntrypointType.HttpHandler,
|
||||
["jakarta.ws.rs.GET"] = EntrypointType.HttpHandler,
|
||||
["jakarta.ws.rs.POST"] = EntrypointType.HttpHandler,
|
||||
["jakarta.ws.rs.PUT"] = EntrypointType.HttpHandler,
|
||||
["jakarta.ws.rs.DELETE"] = EntrypointType.HttpHandler,
|
||||
["jakarta.ws.rs.PATCH"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Micronaut HTTP handlers
|
||||
["io.micronaut.http.annotation.Get"] = EntrypointType.HttpHandler,
|
||||
["io.micronaut.http.annotation.Post"] = EntrypointType.HttpHandler,
|
||||
["io.micronaut.http.annotation.Put"] = EntrypointType.HttpHandler,
|
||||
["io.micronaut.http.annotation.Delete"] = EntrypointType.HttpHandler,
|
||||
["io.micronaut.http.annotation.Patch"] = EntrypointType.HttpHandler,
|
||||
|
||||
// gRPC handlers
|
||||
["org.lognet.springboot.grpc.GRpcService"] = EntrypointType.GrpcMethod,
|
||||
["net.devh.boot.grpc.server.service.GrpcService"] = EntrypointType.GrpcMethod,
|
||||
|
||||
// Scheduled jobs
|
||||
["org.springframework.scheduling.annotation.Scheduled"] = EntrypointType.ScheduledJob,
|
||||
["io.micronaut.scheduling.annotation.Scheduled"] = EntrypointType.ScheduledJob,
|
||||
["javax.ejb.Schedule"] = EntrypointType.ScheduledJob,
|
||||
["jakarta.ejb.Schedule"] = EntrypointType.ScheduledJob,
|
||||
|
||||
// Message handlers (Kafka, RabbitMQ, JMS)
|
||||
["org.springframework.kafka.annotation.KafkaListener"] = EntrypointType.MessageHandler,
|
||||
["org.springframework.amqp.rabbit.annotation.RabbitListener"] = EntrypointType.MessageHandler,
|
||||
["org.springframework.jms.annotation.JmsListener"] = EntrypointType.MessageHandler,
|
||||
["io.micronaut.kafka.annotation.KafkaListener"] = EntrypointType.MessageHandler,
|
||||
["io.micronaut.rabbitmq.annotation.RabbitListener"] = EntrypointType.MessageHandler,
|
||||
|
||||
// Event handlers
|
||||
["org.springframework.context.event.EventListener"] = EntrypointType.EventHandler,
|
||||
["org.springframework.transaction.event.TransactionalEventListener"] = EntrypointType.EventHandler,
|
||||
|
||||
// GraphQL handlers
|
||||
["org.springframework.graphql.data.method.annotation.QueryMapping"] = EntrypointType.HttpHandler,
|
||||
["org.springframework.graphql.data.method.annotation.MutationMapping"] = EntrypointType.HttpHandler,
|
||||
["org.springframework.graphql.data.method.annotation.SubscriptionMapping"] = EntrypointType.HttpHandler,
|
||||
["io.leangen.graphql.annotations.GraphQLQuery"] = EntrypointType.HttpHandler,
|
||||
["io.leangen.graphql.annotations.GraphQLMutation"] = EntrypointType.HttpHandler,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Controller class annotations that indicate all public methods are entrypoints.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ControllerClassAnnotations = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"org.springframework.stereotype.Controller",
|
||||
"org.springframework.web.bind.annotation.RestController",
|
||||
"javax.ws.rs.Path",
|
||||
"jakarta.ws.rs.Path",
|
||||
"io.micronaut.http.annotation.Controller",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a method as an entrypoint.
|
||||
/// </summary>
|
||||
/// <param name="classInfo">The containing class.</param>
|
||||
/// <param name="method">The method to classify.</param>
|
||||
/// <returns>The entrypoint type if the method is an entrypoint; otherwise, null.</returns>
|
||||
public EntrypointType? Classify(JavaClassInfo classInfo, JavaMethodInfo method)
|
||||
{
|
||||
// Check method annotations first
|
||||
foreach (var annotation in method.Annotations)
|
||||
{
|
||||
if (AnnotationMap.TryGetValue(annotation, out var entrypointType))
|
||||
{
|
||||
return entrypointType;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if class is a controller and method is public
|
||||
if (method.IsPublic && !method.IsStatic && !IsSpecialMethod(method.Name))
|
||||
{
|
||||
foreach (var classAnnotation in classInfo.Annotations)
|
||||
{
|
||||
if (ControllerClassAnnotations.Contains(classAnnotation))
|
||||
{
|
||||
// Public methods in controller classes are HTTP handlers by default
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for main method (CLI entry point)
|
||||
if (method.Name == "main" &&
|
||||
method.IsStatic &&
|
||||
method.IsPublic &&
|
||||
method.Descriptor == "([Ljava/lang/String;)V")
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
|
||||
// Check for Servlet methods
|
||||
if (IsServletHandler(classInfo, method))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsSpecialMethod(string name)
|
||||
{
|
||||
return name is "<init>" or "<clinit>" or "toString" or "hashCode" or "equals" or "clone";
|
||||
}
|
||||
|
||||
private static bool IsServletHandler(JavaClassInfo classInfo, JavaMethodInfo method)
|
||||
{
|
||||
// Check if class extends HttpServlet
|
||||
var isServlet = classInfo.SuperClassName?.EndsWith("HttpServlet") == true ||
|
||||
classInfo.Interfaces.Any(i => i.EndsWith("Servlet"));
|
||||
|
||||
if (!isServlet)
|
||||
return false;
|
||||
|
||||
// Check for doGet, doPost, etc.
|
||||
return method.Name is "doGet" or "doPost" or "doPut" or "doDelete" or "doPatch" or "doHead" or "doOptions" or "service";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaModels.cs
|
||||
// Sprint: SPRINT_3610_0001_0001_java_callgraph
|
||||
// Description: Data models for Java bytecode analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Java;
|
||||
|
||||
/// <summary>
|
||||
/// Information about a parsed Java class.
|
||||
/// </summary>
|
||||
public sealed record JavaClassInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Fully qualified class name (e.g., "com.example.UserController").
|
||||
/// </summary>
|
||||
public required string ClassName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Superclass name.
|
||||
/// </summary>
|
||||
public string? SuperClassName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Implemented interfaces.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Interfaces { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Class access flags.
|
||||
/// </summary>
|
||||
public JavaAccessFlags AccessFlags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Class file major version.
|
||||
/// </summary>
|
||||
public int MajorVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Class file minor version.
|
||||
/// </summary>
|
||||
public int MinorVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file name from SourceFile attribute.
|
||||
/// </summary>
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Archive containing this class (JAR/WAR path).
|
||||
/// </summary>
|
||||
public string? SourceArchive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry name within the archive.
|
||||
/// </summary>
|
||||
public string? SourceEntry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Methods defined in this class.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JavaMethodInfo> Methods { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Class-level annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Annotations { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a Java method.
|
||||
/// </summary>
|
||||
public sealed record JavaMethodInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Method name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method descriptor (e.g., "(Ljava/lang/String;)V").
|
||||
/// </summary>
|
||||
public required string Descriptor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method access flags.
|
||||
/// </summary>
|
||||
public JavaAccessFlags AccessFlags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number of method definition.
|
||||
/// </summary>
|
||||
public int LineNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method calls made from this method.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JavaMethodCall> Calls { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Annotations on this method.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Annotations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the method is public.
|
||||
/// </summary>
|
||||
public bool IsPublic => (AccessFlags & JavaAccessFlags.Public) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the method is protected.
|
||||
/// </summary>
|
||||
public bool IsProtected => (AccessFlags & JavaAccessFlags.Protected) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the method is static.
|
||||
/// </summary>
|
||||
public bool IsStatic => (AccessFlags & JavaAccessFlags.Static) != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a method call.
|
||||
/// </summary>
|
||||
public sealed record JavaMethodCall
|
||||
{
|
||||
/// <summary>
|
||||
/// Target class name.
|
||||
/// </summary>
|
||||
public required string TargetClass { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method name.
|
||||
/// </summary>
|
||||
public required string MethodName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method descriptor.
|
||||
/// </summary>
|
||||
public required string Descriptor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Invoke opcode used.
|
||||
/// </summary>
|
||||
public JavaOpcode Opcode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a lambda expression (invokedynamic for lambda).
|
||||
/// </summary>
|
||||
public bool IsLambda { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this call uses invokedynamic.
|
||||
/// </summary>
|
||||
public bool IsDynamic { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Java method access flags.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum JavaAccessFlags : ushort
|
||||
{
|
||||
None = 0,
|
||||
Public = 0x0001,
|
||||
Private = 0x0002,
|
||||
Protected = 0x0004,
|
||||
Static = 0x0008,
|
||||
Final = 0x0010,
|
||||
Synchronized = 0x0020,
|
||||
Bridge = 0x0040,
|
||||
Varargs = 0x0080,
|
||||
Native = 0x0100,
|
||||
Abstract = 0x0400,
|
||||
Strict = 0x0800,
|
||||
Synthetic = 0x1000
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Java bytecode opcodes for method invocations.
|
||||
/// </summary>
|
||||
public enum JavaOpcode : byte
|
||||
{
|
||||
// Constants
|
||||
Nop = 0x00,
|
||||
AconstNull = 0x01,
|
||||
IconstM1 = 0x02,
|
||||
Iconst0 = 0x03,
|
||||
Iconst1 = 0x04,
|
||||
Iconst2 = 0x05,
|
||||
Iconst3 = 0x06,
|
||||
Iconst4 = 0x07,
|
||||
Iconst5 = 0x08,
|
||||
Lconst0 = 0x09,
|
||||
Lconst1 = 0x0a,
|
||||
Fconst0 = 0x0b,
|
||||
Fconst1 = 0x0c,
|
||||
Fconst2 = 0x0d,
|
||||
Dconst0 = 0x0e,
|
||||
Dconst1 = 0x0f,
|
||||
Bipush = 0x10,
|
||||
Sipush = 0x11,
|
||||
Ldc = 0x12,
|
||||
Ldc_w = 0x13,
|
||||
Ldc2_w = 0x14,
|
||||
|
||||
// Loads
|
||||
Iload = 0x15,
|
||||
Lload = 0x16,
|
||||
Fload = 0x17,
|
||||
Dload = 0x18,
|
||||
Aload = 0x19,
|
||||
Iload_0 = 0x1a,
|
||||
Iload_1 = 0x1b,
|
||||
Iload_2 = 0x1c,
|
||||
Iload_3 = 0x1d,
|
||||
Lload_0 = 0x1e,
|
||||
Lload_1 = 0x1f,
|
||||
Lload_2 = 0x20,
|
||||
Lload_3 = 0x21,
|
||||
Fload_0 = 0x22,
|
||||
Fload_1 = 0x23,
|
||||
Fload_2 = 0x24,
|
||||
Fload_3 = 0x25,
|
||||
Dload_0 = 0x26,
|
||||
Dload_1 = 0x27,
|
||||
Dload_2 = 0x28,
|
||||
Dload_3 = 0x29,
|
||||
Aload_0 = 0x2a,
|
||||
Aload_1 = 0x2b,
|
||||
Aload_2 = 0x2c,
|
||||
Aload_3 = 0x2d,
|
||||
Iaload = 0x2e,
|
||||
Laload = 0x2f,
|
||||
Faload = 0x30,
|
||||
Daload = 0x31,
|
||||
Aaload = 0x32,
|
||||
Baload = 0x33,
|
||||
Caload = 0x34,
|
||||
Saload = 0x35,
|
||||
|
||||
// Stores
|
||||
Istore = 0x36,
|
||||
Lstore = 0x37,
|
||||
Fstore = 0x38,
|
||||
Dstore = 0x39,
|
||||
Astore = 0x3a,
|
||||
Istore_0 = 0x3b,
|
||||
Istore_1 = 0x3c,
|
||||
Istore_2 = 0x3d,
|
||||
Istore_3 = 0x3e,
|
||||
Lstore_0 = 0x3f,
|
||||
Lstore_1 = 0x40,
|
||||
Lstore_2 = 0x41,
|
||||
Lstore_3 = 0x42,
|
||||
Fstore_0 = 0x43,
|
||||
Fstore_1 = 0x44,
|
||||
Fstore_2 = 0x45,
|
||||
Fstore_3 = 0x46,
|
||||
Dstore_0 = 0x47,
|
||||
Dstore_1 = 0x48,
|
||||
Dstore_2 = 0x49,
|
||||
Dstore_3 = 0x4a,
|
||||
Astore_0 = 0x4b,
|
||||
Astore_1 = 0x4c,
|
||||
Astore_2 = 0x4d,
|
||||
Astore_3 = 0x4e,
|
||||
Iastore = 0x4f,
|
||||
Lastore = 0x50,
|
||||
Fastore = 0x51,
|
||||
Dastore = 0x52,
|
||||
Aastore = 0x53,
|
||||
Bastore = 0x54,
|
||||
Castore = 0x55,
|
||||
Sastore = 0x56,
|
||||
|
||||
// Stack
|
||||
Pop = 0x57,
|
||||
Pop2 = 0x58,
|
||||
Dup = 0x59,
|
||||
DupX1 = 0x5a,
|
||||
DupX2 = 0x5b,
|
||||
Dup2 = 0x5c,
|
||||
Dup2X1 = 0x5d,
|
||||
Dup2X2 = 0x5e,
|
||||
Swap = 0x5f,
|
||||
|
||||
// Math
|
||||
Iadd = 0x60,
|
||||
Ladd = 0x61,
|
||||
Fadd = 0x62,
|
||||
Dadd = 0x63,
|
||||
Isub = 0x64,
|
||||
Lsub = 0x65,
|
||||
Fsub = 0x66,
|
||||
Dsub = 0x67,
|
||||
Imul = 0x68,
|
||||
Lmul = 0x69,
|
||||
Fmul = 0x6a,
|
||||
Dmul = 0x6b,
|
||||
Idiv = 0x6c,
|
||||
Ldiv = 0x6d,
|
||||
Fdiv = 0x6e,
|
||||
Ddiv = 0x6f,
|
||||
Irem = 0x70,
|
||||
Lrem = 0x71,
|
||||
Frem = 0x72,
|
||||
Drem = 0x73,
|
||||
Ineg = 0x74,
|
||||
Lneg = 0x75,
|
||||
Fneg = 0x76,
|
||||
Dneg = 0x77,
|
||||
Ishl = 0x78,
|
||||
Lshl = 0x79,
|
||||
Ishr = 0x7a,
|
||||
Lshr = 0x7b,
|
||||
Iushr = 0x7c,
|
||||
Lushr = 0x7d,
|
||||
Iand = 0x7e,
|
||||
Land = 0x7f,
|
||||
Ior = 0x80,
|
||||
Lor = 0x81,
|
||||
Ixor = 0x82,
|
||||
Lxor = 0x83,
|
||||
Iinc = 0x84,
|
||||
|
||||
// Conversions
|
||||
I2l = 0x85,
|
||||
I2f = 0x86,
|
||||
I2d = 0x87,
|
||||
L2i = 0x88,
|
||||
L2f = 0x89,
|
||||
L2d = 0x8a,
|
||||
F2i = 0x8b,
|
||||
F2l = 0x8c,
|
||||
F2d = 0x8d,
|
||||
D2i = 0x8e,
|
||||
D2l = 0x8f,
|
||||
D2f = 0x90,
|
||||
I2b = 0x91,
|
||||
I2c = 0x92,
|
||||
I2s = 0x93,
|
||||
|
||||
// Comparisons
|
||||
Lcmp = 0x94,
|
||||
Fcmpl = 0x95,
|
||||
Fcmpg = 0x96,
|
||||
Dcmpl = 0x97,
|
||||
Dcmpg = 0x98,
|
||||
Ifeq = 0x99,
|
||||
Ifne = 0x9a,
|
||||
Iflt = 0x9b,
|
||||
Ifge = 0x9c,
|
||||
Ifgt = 0x9d,
|
||||
Ifle = 0x9e,
|
||||
IfIcmpeq = 0x9f,
|
||||
IfIcmpne = 0xa0,
|
||||
IfIcmplt = 0xa1,
|
||||
IfIcmpge = 0xa2,
|
||||
IfIcmpgt = 0xa3,
|
||||
IfIcmple = 0xa4,
|
||||
IfAcmpeq = 0xa5,
|
||||
IfAcmpne = 0xa6,
|
||||
|
||||
// Control
|
||||
Goto = 0xa7,
|
||||
Jsr = 0xa8,
|
||||
Ret = 0xa9,
|
||||
Tableswitch = 0xaa,
|
||||
Lookupswitch = 0xab,
|
||||
Ireturn = 0xac,
|
||||
Lreturn = 0xad,
|
||||
Freturn = 0xae,
|
||||
Dreturn = 0xaf,
|
||||
Areturn = 0xb0,
|
||||
Return = 0xb1,
|
||||
|
||||
// References
|
||||
Getstatic = 0xb2,
|
||||
Putstatic = 0xb3,
|
||||
Getfield = 0xb4,
|
||||
Putfield = 0xb5,
|
||||
InvokeVirtual = 0xb6,
|
||||
InvokeSpecial = 0xb7,
|
||||
InvokeStatic = 0xb8,
|
||||
InvokeInterface = 0xb9,
|
||||
InvokeDynamic = 0xba,
|
||||
New = 0xbb,
|
||||
Newarray = 0xbc,
|
||||
Anewarray = 0xbd,
|
||||
Arraylength = 0xbe,
|
||||
Athrow = 0xbf,
|
||||
Checkcast = 0xc0,
|
||||
Instanceof = 0xc1,
|
||||
Monitorenter = 0xc2,
|
||||
Monitorexit = 0xc3,
|
||||
|
||||
// Extended
|
||||
Wide = 0xc4,
|
||||
Multianewarray = 0xc5,
|
||||
Ifnull = 0xc6,
|
||||
Ifnonnull = 0xc7,
|
||||
Goto_w = 0xc8,
|
||||
Jsr_w = 0xc9
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaSinkMatcher.cs
|
||||
// Sprint: SPRINT_3610_0001_0001_java_callgraph
|
||||
// Description: Matches Java method calls to known security sinks.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Java;
|
||||
|
||||
/// <summary>
|
||||
/// Matches Java method calls to known security-relevant sinks.
|
||||
/// </summary>
|
||||
public sealed class JavaSinkMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of known Java sinks.
|
||||
/// </summary>
|
||||
private static readonly List<JavaSinkPattern> SinkPatterns =
|
||||
[
|
||||
// Command injection
|
||||
new("java.lang.Runtime", "exec", SinkCategory.CmdExec),
|
||||
new("java.lang.ProcessBuilder", "<init>", SinkCategory.CmdExec),
|
||||
new("java.lang.ProcessBuilder", "command", SinkCategory.CmdExec),
|
||||
|
||||
// SQL injection
|
||||
new("java.sql.Statement", "executeQuery", SinkCategory.SqlRaw),
|
||||
new("java.sql.Statement", "executeUpdate", SinkCategory.SqlRaw),
|
||||
new("java.sql.Statement", "execute", SinkCategory.SqlRaw),
|
||||
new("java.sql.Connection", "prepareStatement", SinkCategory.SqlRaw),
|
||||
new("java.sql.Connection", "prepareCall", SinkCategory.SqlRaw),
|
||||
new("org.springframework.jdbc.core.JdbcTemplate", "query", SinkCategory.SqlRaw),
|
||||
new("org.springframework.jdbc.core.JdbcTemplate", "update", SinkCategory.SqlRaw),
|
||||
new("org.springframework.jdbc.core.JdbcTemplate", "execute", SinkCategory.SqlRaw),
|
||||
new("javax.persistence.EntityManager", "createQuery", SinkCategory.SqlRaw),
|
||||
new("javax.persistence.EntityManager", "createNativeQuery", SinkCategory.SqlRaw),
|
||||
new("jakarta.persistence.EntityManager", "createQuery", SinkCategory.SqlRaw),
|
||||
new("jakarta.persistence.EntityManager", "createNativeQuery", SinkCategory.SqlRaw),
|
||||
|
||||
// LDAP injection
|
||||
new("javax.naming.directory.DirContext", "search", SinkCategory.LdapInjection),
|
||||
new("javax.naming.ldap.LdapContext", "search", SinkCategory.LdapInjection),
|
||||
|
||||
// XPath injection
|
||||
new("javax.xml.xpath.XPath", "evaluate", SinkCategory.XPathInjection),
|
||||
new("javax.xml.xpath.XPath", "compile", SinkCategory.XPathInjection),
|
||||
|
||||
// XML External Entity (XXE)
|
||||
new("javax.xml.parsers.DocumentBuilderFactory", "newInstance", SinkCategory.XxeInjection),
|
||||
new("javax.xml.parsers.SAXParserFactory", "newInstance", SinkCategory.XxeInjection),
|
||||
new("javax.xml.stream.XMLInputFactory", "newInstance", SinkCategory.XxeInjection),
|
||||
new("org.xml.sax.XMLReader", "parse", SinkCategory.XxeInjection),
|
||||
|
||||
// Deserialization
|
||||
new("java.io.ObjectInputStream", "readObject", SinkCategory.UnsafeDeser),
|
||||
new("java.io.ObjectInputStream", "readUnshared", SinkCategory.UnsafeDeser),
|
||||
new("java.beans.XMLDecoder", "readObject", SinkCategory.UnsafeDeser),
|
||||
new("com.fasterxml.jackson.databind.ObjectMapper", "readValue", SinkCategory.UnsafeDeser),
|
||||
new("com.google.gson.Gson", "fromJson", SinkCategory.UnsafeDeser),
|
||||
new("org.yaml.snakeyaml.Yaml", "load", SinkCategory.UnsafeDeser),
|
||||
new("org.yaml.snakeyaml.Yaml", "loadAll", SinkCategory.UnsafeDeser),
|
||||
|
||||
// Path traversal
|
||||
new("java.io.File", "<init>", SinkCategory.PathTraversal),
|
||||
new("java.io.FileInputStream", "<init>", SinkCategory.PathTraversal),
|
||||
new("java.io.FileOutputStream", "<init>", SinkCategory.PathTraversal),
|
||||
new("java.io.FileReader", "<init>", SinkCategory.PathTraversal),
|
||||
new("java.io.FileWriter", "<init>", SinkCategory.PathTraversal),
|
||||
new("java.nio.file.Paths", "get", SinkCategory.PathTraversal),
|
||||
new("java.nio.file.Files", "newInputStream", SinkCategory.PathTraversal),
|
||||
new("java.nio.file.Files", "newOutputStream", SinkCategory.PathTraversal),
|
||||
new("java.nio.file.Files", "readAllBytes", SinkCategory.PathTraversal),
|
||||
new("java.nio.file.Files", "write", SinkCategory.PathTraversal),
|
||||
new("java.nio.file.Files", "copy", SinkCategory.PathTraversal),
|
||||
new("java.nio.file.Files", "move", SinkCategory.PathTraversal),
|
||||
new("java.nio.file.Files", "delete", SinkCategory.PathTraversal),
|
||||
|
||||
// SSRF
|
||||
new("java.net.URL", "openConnection", SinkCategory.Ssrf),
|
||||
new("java.net.URL", "openStream", SinkCategory.Ssrf),
|
||||
new("java.net.HttpURLConnection", "connect", SinkCategory.Ssrf),
|
||||
new("org.apache.http.client.HttpClient", "execute", SinkCategory.Ssrf),
|
||||
new("org.apache.http.impl.client.CloseableHttpClient", "execute", SinkCategory.Ssrf),
|
||||
new("okhttp3.OkHttpClient", "newCall", SinkCategory.Ssrf),
|
||||
new("org.springframework.web.client.RestTemplate", "getForObject", SinkCategory.Ssrf),
|
||||
new("org.springframework.web.client.RestTemplate", "postForObject", SinkCategory.Ssrf),
|
||||
new("org.springframework.web.client.RestTemplate", "exchange", SinkCategory.Ssrf),
|
||||
new("org.springframework.web.reactive.function.client.WebClient", "get", SinkCategory.Ssrf),
|
||||
new("org.springframework.web.reactive.function.client.WebClient", "post", SinkCategory.Ssrf),
|
||||
|
||||
// Code injection / Expression Language
|
||||
new("javax.script.ScriptEngine", "eval", SinkCategory.CodeInjection),
|
||||
new("org.mozilla.javascript.Context", "evaluateString", SinkCategory.CodeInjection),
|
||||
new("groovy.lang.GroovyShell", "evaluate", SinkCategory.CodeInjection),
|
||||
new("org.springframework.expression.ExpressionParser", "parseExpression", SinkCategory.CodeInjection),
|
||||
new("org.springframework.expression.Expression", "getValue", SinkCategory.CodeInjection),
|
||||
new("javax.el.ExpressionFactory", "createValueExpression", SinkCategory.CodeInjection),
|
||||
new("org.mvel2.MVEL", "eval", SinkCategory.CodeInjection),
|
||||
new("ognl.Ognl", "getValue", SinkCategory.CodeInjection),
|
||||
new("ognl.Ognl", "parseExpression", SinkCategory.CodeInjection),
|
||||
|
||||
// Log injection
|
||||
new("org.slf4j.Logger", "info", SinkCategory.LogInjection),
|
||||
new("org.slf4j.Logger", "debug", SinkCategory.LogInjection),
|
||||
new("org.slf4j.Logger", "warn", SinkCategory.LogInjection),
|
||||
new("org.slf4j.Logger", "error", SinkCategory.LogInjection),
|
||||
new("org.apache.logging.log4j.Logger", "info", SinkCategory.LogInjection),
|
||||
new("org.apache.logging.log4j.Logger", "debug", SinkCategory.LogInjection),
|
||||
new("org.apache.logging.log4j.Logger", "warn", SinkCategory.LogInjection),
|
||||
new("org.apache.logging.log4j.Logger", "error", SinkCategory.LogInjection),
|
||||
new("java.util.logging.Logger", "log", SinkCategory.LogInjection),
|
||||
new("java.util.logging.Logger", "info", SinkCategory.LogInjection),
|
||||
new("java.util.logging.Logger", "warning", SinkCategory.LogInjection),
|
||||
|
||||
// Template injection
|
||||
new("freemarker.template.Template", "process", SinkCategory.TemplateInjection),
|
||||
new("org.apache.velocity.Template", "merge", SinkCategory.TemplateInjection),
|
||||
new("org.thymeleaf.TemplateEngine", "process", SinkCategory.TemplateInjection),
|
||||
new("org.thymeleaf.ITemplateEngine", "process", SinkCategory.TemplateInjection),
|
||||
new("com.github.jknack.handlebars.Handlebars", "compileInline", SinkCategory.TemplateInjection),
|
||||
new("pebble.template.PebbleTemplate", "evaluate", SinkCategory.TemplateInjection),
|
||||
|
||||
// Reflection
|
||||
new("java.lang.Class", "forName", SinkCategory.Reflection),
|
||||
new("java.lang.Class", "getMethod", SinkCategory.Reflection),
|
||||
new("java.lang.Class", "getDeclaredMethod", SinkCategory.Reflection),
|
||||
new("java.lang.Class", "newInstance", SinkCategory.Reflection),
|
||||
new("java.lang.reflect.Method", "invoke", SinkCategory.Reflection),
|
||||
new("java.lang.reflect.Constructor", "newInstance", SinkCategory.Reflection),
|
||||
|
||||
// Cryptographic issues
|
||||
new("java.security.MessageDigest", "getInstance", SinkCategory.CryptoWeak),
|
||||
new("javax.crypto.Cipher", "getInstance", SinkCategory.CryptoWeak),
|
||||
new("java.util.Random", "<init>", SinkCategory.CryptoWeak),
|
||||
new("java.util.Random", "nextInt", SinkCategory.CryptoWeak),
|
||||
new("java.util.Random", "nextLong", SinkCategory.CryptoWeak),
|
||||
|
||||
// Open redirect
|
||||
new("javax.servlet.http.HttpServletResponse", "sendRedirect", SinkCategory.OpenRedirect),
|
||||
new("jakarta.servlet.http.HttpServletResponse", "sendRedirect", SinkCategory.OpenRedirect),
|
||||
new("org.springframework.web.servlet.view.RedirectView", "<init>", SinkCategory.OpenRedirect),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Matches a method call to a sink category.
|
||||
/// </summary>
|
||||
/// <param name="className">Target class name.</param>
|
||||
/// <param name="methodName">Method name.</param>
|
||||
/// <param name="descriptor">Method descriptor (optional).</param>
|
||||
/// <returns>Sink category if matched; otherwise, null.</returns>
|
||||
public SinkCategory? Match(string className, string methodName, string? descriptor = null)
|
||||
{
|
||||
foreach (var pattern in SinkPatterns)
|
||||
{
|
||||
if (pattern.Matches(className, methodName))
|
||||
{
|
||||
return pattern.Category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for matching Java sinks.
|
||||
/// </summary>
|
||||
internal sealed record JavaSinkPattern(string ClassName, string MethodName, SinkCategory Category)
|
||||
{
|
||||
public bool Matches(string className, string methodName)
|
||||
{
|
||||
return string.Equals(className, ClassName, StringComparison.Ordinal) &&
|
||||
string.Equals(methodName, MethodName, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaSymbolIdBuilder.cs
|
||||
// Sprint: SPRINT_3610_0001_0001_java_callgraph
|
||||
// Description: Builds stable, deterministic symbol IDs for Java methods.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Java;
|
||||
|
||||
/// <summary>
|
||||
/// Builds stable, deterministic symbol IDs for Java classes and methods.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Symbol ID format:
|
||||
/// - Class: java:{package}.{class}
|
||||
/// - Method: java:{package}.{class}.{method}({paramTypes}){returnType}
|
||||
/// - Inner class: java:{package}.{outer}${inner}
|
||||
/// </remarks>
|
||||
public static partial class JavaSymbolIdBuilder
|
||||
{
|
||||
private const string Prefix = "java:";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a symbol ID for a Java class.
|
||||
/// </summary>
|
||||
/// <param name="className">The fully qualified class name (e.g., "com.example.UserService").</param>
|
||||
/// <returns>A stable symbol ID.</returns>
|
||||
public static string BuildClassId(string className)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(className);
|
||||
return $"{Prefix}{NormalizeClassName(className)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a symbol ID for a Java method.
|
||||
/// </summary>
|
||||
/// <param name="className">The fully qualified class name.</param>
|
||||
/// <param name="methodName">The method name.</param>
|
||||
/// <param name="descriptor">The method descriptor (e.g., "(Ljava/lang/String;)V").</param>
|
||||
/// <returns>A stable symbol ID.</returns>
|
||||
public static string BuildMethodId(string className, string methodName, string descriptor)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(className);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(methodName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(descriptor);
|
||||
|
||||
var normalizedClass = NormalizeClassName(className);
|
||||
var normalizedDescriptor = NormalizeDescriptor(descriptor);
|
||||
|
||||
return $"{Prefix}{normalizedClass}.{methodName}{normalizedDescriptor}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a symbol ID for a Java method without descriptor (less precise).
|
||||
/// </summary>
|
||||
/// <param name="className">The fully qualified class name.</param>
|
||||
/// <param name="methodName">The method name.</param>
|
||||
/// <returns>A stable symbol ID.</returns>
|
||||
public static string BuildMethodId(string className, string methodName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(className);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(methodName);
|
||||
|
||||
return $"{Prefix}{NormalizeClassName(className)}.{methodName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a symbol ID into its components.
|
||||
/// </summary>
|
||||
/// <param name="symbolId">The symbol ID to parse.</param>
|
||||
/// <returns>The parsed components, or null if invalid.</returns>
|
||||
public static JavaSymbolComponents? Parse(string symbolId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbolId) || !symbolId.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var remainder = symbolId[Prefix.Length..];
|
||||
|
||||
// Check for method (contains parentheses)
|
||||
var parenIndex = remainder.IndexOf('(');
|
||||
if (parenIndex > 0)
|
||||
{
|
||||
// It's a method
|
||||
var lastDot = remainder.LastIndexOf('.', parenIndex);
|
||||
if (lastDot < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new JavaSymbolComponents
|
||||
{
|
||||
ClassName = remainder[..lastDot],
|
||||
MethodName = remainder[(lastDot + 1)..parenIndex],
|
||||
Descriptor = remainder[parenIndex..]
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a class or a method without descriptor
|
||||
var parts = remainder.Split('.');
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
// Last part could be method name (starts with lowercase) or class name
|
||||
var lastPart = parts[^1];
|
||||
if (char.IsLower(lastPart[0]) || lastPart == "<init>" || lastPart == "<clinit>")
|
||||
{
|
||||
// Likely a method
|
||||
var className = string.Join(".", parts[..^1]);
|
||||
return new JavaSymbolComponents
|
||||
{
|
||||
ClassName = className,
|
||||
MethodName = lastPart,
|
||||
Descriptor = null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// It's a class
|
||||
return new JavaSymbolComponents
|
||||
{
|
||||
ClassName = remainder,
|
||||
MethodName = null,
|
||||
Descriptor = null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a JVM internal class name to standard format.
|
||||
/// </summary>
|
||||
/// <param name="internalName">JVM internal name (e.g., "com/example/UserService").</param>
|
||||
/// <returns>Standard format (e.g., "com.example.UserService").</returns>
|
||||
public static string InternalToStandard(string internalName)
|
||||
{
|
||||
return internalName.Replace('/', '.');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a standard class name to JVM internal format.
|
||||
/// </summary>
|
||||
/// <param name="standardName">Standard name (e.g., "com.example.UserService").</param>
|
||||
/// <returns>JVM internal format (e.g., "com/example/UserService").</returns>
|
||||
public static string StandardToInternal(string standardName)
|
||||
{
|
||||
return standardName.Replace('.', '/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a method descriptor into human-readable form.
|
||||
/// </summary>
|
||||
/// <param name="descriptor">JVM method descriptor.</param>
|
||||
/// <returns>Human-readable signature.</returns>
|
||||
public static string ParseDescriptorToSignature(string descriptor)
|
||||
{
|
||||
if (string.IsNullOrEmpty(descriptor) || !descriptor.StartsWith('('))
|
||||
{
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
var result = new StringBuilder();
|
||||
var parenEnd = descriptor.IndexOf(')');
|
||||
if (parenEnd < 0)
|
||||
{
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
var paramTypes = ParseTypeList(descriptor[1..parenEnd]);
|
||||
var returnType = ParseType(descriptor[(parenEnd + 1)..]);
|
||||
|
||||
result.Append('(');
|
||||
result.Append(string.Join(", ", paramTypes));
|
||||
result.Append(") -> ");
|
||||
result.Append(returnType);
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static string NormalizeClassName(string className)
|
||||
{
|
||||
// Convert internal format to standard if needed
|
||||
return className.Replace('/', '.');
|
||||
}
|
||||
|
||||
private static string NormalizeDescriptor(string descriptor)
|
||||
{
|
||||
// Keep descriptor as-is (it's already deterministic)
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private static List<string> ParseTypeList(string types)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var i = 0;
|
||||
|
||||
while (i < types.Length)
|
||||
{
|
||||
var (type, consumed) = ParseTypeAt(types, i);
|
||||
result.Add(type);
|
||||
i += consumed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ParseType(string type)
|
||||
{
|
||||
var (parsed, _) = ParseTypeAt(type, 0);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static (string Type, int Consumed) ParseTypeAt(string types, int index)
|
||||
{
|
||||
if (index >= types.Length)
|
||||
{
|
||||
return ("void", 0);
|
||||
}
|
||||
|
||||
return types[index] switch
|
||||
{
|
||||
'B' => ("byte", 1),
|
||||
'C' => ("char", 1),
|
||||
'D' => ("double", 1),
|
||||
'F' => ("float", 1),
|
||||
'I' => ("int", 1),
|
||||
'J' => ("long", 1),
|
||||
'S' => ("short", 1),
|
||||
'Z' => ("boolean", 1),
|
||||
'V' => ("void", 1),
|
||||
'[' => ParseArrayType(types, index),
|
||||
'L' => ParseObjectType(types, index),
|
||||
_ => (types[index].ToString(), 1)
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Type, int Consumed) ParseArrayType(string types, int index)
|
||||
{
|
||||
var dimensions = 0;
|
||||
var i = index;
|
||||
|
||||
while (i < types.Length && types[i] == '[')
|
||||
{
|
||||
dimensions++;
|
||||
i++;
|
||||
}
|
||||
|
||||
var (elementType, consumed) = ParseTypeAt(types, i);
|
||||
return ($"{elementType}{"[]".PadRight(dimensions * 2, ']')}", i - index + consumed);
|
||||
}
|
||||
|
||||
private static (string Type, int Consumed) ParseObjectType(string types, int index)
|
||||
{
|
||||
var semicolon = types.IndexOf(';', index);
|
||||
if (semicolon < 0)
|
||||
{
|
||||
return ("Object", types.Length - index);
|
||||
}
|
||||
|
||||
var internalName = types[(index + 1)..semicolon];
|
||||
var simpleName = internalName.Replace('/', '.');
|
||||
|
||||
// Use simple name for common types
|
||||
if (simpleName.StartsWith("java.lang."))
|
||||
{
|
||||
simpleName = simpleName["java.lang.".Length..];
|
||||
}
|
||||
|
||||
return (simpleName, semicolon - index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed components of a Java symbol ID.
|
||||
/// </summary>
|
||||
public sealed record JavaSymbolComponents
|
||||
{
|
||||
/// <summary>
|
||||
/// The fully qualified class name.
|
||||
/// </summary>
|
||||
public required string ClassName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The method name if this is a method, null for classes.
|
||||
/// </summary>
|
||||
public string? MethodName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The method descriptor if available.
|
||||
/// </summary>
|
||||
public string? Descriptor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a method (has a method name).
|
||||
/// </summary>
|
||||
public bool IsMethod => MethodName is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a constructor.
|
||||
/// </summary>
|
||||
public bool IsConstructor => MethodName == "<init>";
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a static initializer.
|
||||
/// </summary>
|
||||
public bool IsStaticInitializer => MethodName == "<clinit>";
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaScriptCallGraphExtractor.cs
|
||||
// Sprint: SPRINT_3610_0003_0001_nodejs_callgraph
|
||||
// Description: JavaScript/TypeScript call graph extractor using AST analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.JavaScript;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call graphs from JavaScript/TypeScript source code.
|
||||
/// Supports Node.js, Express, Fastify, NestJS, Next.js, and other frameworks.
|
||||
/// </summary>
|
||||
public sealed class JavaScriptCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly ILogger<JavaScriptCallGraphExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsEntrypointClassifier _entrypointClassifier;
|
||||
private readonly JsSinkMatcher _sinkMatcher;
|
||||
|
||||
public JavaScriptCallGraphExtractor(
|
||||
ILogger<JavaScriptCallGraphExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_entrypointClassifier = new JsEntrypointClassifier();
|
||||
_sinkMatcher = new JsSinkMatcher();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Language => "javascript";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var targetPath = Path.GetFullPath(request.TargetPath);
|
||||
var packageJsonPath = FindPackageJson(targetPath);
|
||||
|
||||
if (packageJsonPath is null)
|
||||
{
|
||||
throw new FileNotFoundException($"No package.json found at or above: {targetPath}");
|
||||
}
|
||||
|
||||
var projectDir = Path.GetDirectoryName(packageJsonPath)!;
|
||||
_logger.LogDebug("Starting JavaScript call graph extraction for project at {Path}", projectDir);
|
||||
|
||||
// Read package.json for metadata
|
||||
var packageInfo = await ReadPackageJsonAsync(packageJsonPath, cancellationToken);
|
||||
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
|
||||
// Find all JS/TS files
|
||||
var sourceFiles = GetSourceFiles(projectDir);
|
||||
_logger.LogDebug("Found {Count} JavaScript/TypeScript files", sourceFiles.Count);
|
||||
|
||||
foreach (var sourceFile in sourceFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(sourceFile, cancellationToken);
|
||||
var relativePath = Path.GetRelativePath(projectDir, sourceFile);
|
||||
|
||||
var functions = ExtractFunctions(content, relativePath, packageInfo.Name);
|
||||
|
||||
foreach (var func in functions)
|
||||
{
|
||||
var entrypointType = _entrypointClassifier.Classify(func);
|
||||
var sinkCategory = _sinkMatcher.Match(func.Module, func.Name);
|
||||
|
||||
var node = new CallGraphNode(
|
||||
NodeId: func.NodeId,
|
||||
Symbol: func.FullName,
|
||||
File: relativePath,
|
||||
Line: func.Line,
|
||||
Package: packageInfo.Name,
|
||||
Visibility: func.IsExported ? Visibility.Public : Visibility.Private,
|
||||
IsEntrypoint: entrypointType.HasValue,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: sinkCategory.HasValue,
|
||||
SinkCategory: sinkCategory);
|
||||
|
||||
nodesById.TryAdd(node.NodeId, node);
|
||||
|
||||
// Extract function calls
|
||||
foreach (var call in func.Calls)
|
||||
{
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: func.NodeId,
|
||||
TargetId: call.TargetNodeId,
|
||||
CallKind: call.IsDynamic ? CallKind.Delegate : CallKind.Direct,
|
||||
CallSite: $"{relativePath}:{call.Line}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse {File}", sourceFile);
|
||||
}
|
||||
}
|
||||
|
||||
return BuildSnapshot(request.ScanId, nodesById, edges);
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildSnapshot(
|
||||
string scanId,
|
||||
Dictionary<string, CallGraphNode> nodesById,
|
||||
HashSet<CallGraphEdge> edges)
|
||||
{
|
||||
var nodes = nodesById.Values
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypointIds = nodes
|
||||
.Where(n => n.IsEntrypoint)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinkIds = nodes
|
||||
.Where(n => n.IsSink)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: nodes,
|
||||
Edges: orderedEdges,
|
||||
EntrypointIds: entrypointIds,
|
||||
SinkIds: sinkIds);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
|
||||
_logger.LogInformation(
|
||||
"JavaScript call graph extracted: {Nodes} nodes, {Edges} edges, {Entrypoints} entrypoints, {Sinks} sinks",
|
||||
nodes.Length, orderedEdges.Length, entrypointIds.Length, sinkIds.Length);
|
||||
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private static string? FindPackageJson(string startPath)
|
||||
{
|
||||
var current = startPath;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
var packageJsonPath = Path.Combine(current, "package.json");
|
||||
if (File.Exists(packageJsonPath))
|
||||
{
|
||||
return packageJsonPath;
|
||||
}
|
||||
current = Path.GetDirectoryName(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<PackageInfo> ReadPackageJsonAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, ct);
|
||||
var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
return new PackageInfo
|
||||
{
|
||||
Name = root.TryGetProperty("name", out var name) ? name.GetString() ?? "unknown" : "unknown",
|
||||
Version = root.TryGetProperty("version", out var version) ? version.GetString() : null,
|
||||
Main = root.TryGetProperty("main", out var main) ? main.GetString() : null,
|
||||
Type = root.TryGetProperty("type", out var type) ? type.GetString() : null
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> GetSourceFiles(string projectDir)
|
||||
{
|
||||
var extensions = new[] { "*.js", "*.ts", "*.jsx", "*.tsx", "*.mjs", "*.cjs" };
|
||||
var excludeDirs = new[] { "node_modules", "dist", "build", ".next", "coverage", "__tests__", "test" };
|
||||
|
||||
return extensions
|
||||
.SelectMany(ext => Directory.EnumerateFiles(projectDir, ext, SearchOption.AllDirectories))
|
||||
.Where(f => !excludeDirs.Any(d => f.Contains(Path.DirectorySeparatorChar + d + Path.DirectorySeparatorChar)))
|
||||
.Where(f => !f.EndsWith(".test.js") && !f.EndsWith(".test.ts") && !f.EndsWith(".spec.js") && !f.EndsWith(".spec.ts"))
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<JsFunctionInfo> ExtractFunctions(string content, string file, string packageName)
|
||||
{
|
||||
var functions = new List<JsFunctionInfo>();
|
||||
|
||||
// Extract function declarations: function name(...) { }
|
||||
ExtractFunctionDeclarations(content, file, packageName, functions);
|
||||
|
||||
// Extract arrow functions: const name = (...) => { }
|
||||
ExtractArrowFunctions(content, file, packageName, functions);
|
||||
|
||||
// Extract class methods: class Foo { method() { } }
|
||||
ExtractClassMethods(content, file, packageName, functions);
|
||||
|
||||
// Extract Express/Fastify route handlers
|
||||
ExtractRouteHandlers(content, file, packageName, functions);
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
private static void ExtractFunctionDeclarations(string content, string file, string packageName, List<JsFunctionInfo> functions)
|
||||
{
|
||||
var pattern = new Regex(
|
||||
@"(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)",
|
||||
RegexOptions.Multiline);
|
||||
|
||||
foreach (Match match in pattern.Matches(content))
|
||||
{
|
||||
var funcName = match.Groups[1].Value;
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
var isExported = match.Value.Contains("export");
|
||||
var moduleBase = Path.GetFileNameWithoutExtension(file);
|
||||
|
||||
functions.Add(new JsFunctionInfo
|
||||
{
|
||||
NodeId = $"js:{packageName}/{moduleBase}.{funcName}",
|
||||
Name = funcName,
|
||||
FullName = $"{moduleBase}.{funcName}",
|
||||
Module = moduleBase,
|
||||
Line = lineNumber,
|
||||
IsExported = isExported,
|
||||
IsAsync = match.Value.Contains("async"),
|
||||
Calls = ExtractCallsFromFunction(content, match.Index)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractArrowFunctions(string content, string file, string packageName, List<JsFunctionInfo> functions)
|
||||
{
|
||||
var pattern = new Regex(
|
||||
@"(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>",
|
||||
RegexOptions.Multiline);
|
||||
|
||||
foreach (Match match in pattern.Matches(content))
|
||||
{
|
||||
var funcName = match.Groups[1].Value;
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
var isExported = match.Value.Contains("export");
|
||||
var moduleBase = Path.GetFileNameWithoutExtension(file);
|
||||
|
||||
functions.Add(new JsFunctionInfo
|
||||
{
|
||||
NodeId = $"js:{packageName}/{moduleBase}.{funcName}",
|
||||
Name = funcName,
|
||||
FullName = $"{moduleBase}.{funcName}",
|
||||
Module = moduleBase,
|
||||
Line = lineNumber,
|
||||
IsExported = isExported,
|
||||
IsAsync = match.Value.Contains("async"),
|
||||
Calls = ExtractCallsFromFunction(content, match.Index)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractClassMethods(string content, string file, string packageName, List<JsFunctionInfo> functions)
|
||||
{
|
||||
// Match class declaration
|
||||
var classPattern = new Regex(@"class\s+(\w+)(?:\s+extends\s+\w+)?\s*\{", RegexOptions.Multiline);
|
||||
|
||||
foreach (Match classMatch in classPattern.Matches(content))
|
||||
{
|
||||
var className = classMatch.Groups[1].Value;
|
||||
var classStart = classMatch.Index;
|
||||
|
||||
// Find matching closing brace (simplified)
|
||||
var methodPattern = new Regex(
|
||||
@"(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{",
|
||||
RegexOptions.Multiline);
|
||||
|
||||
var classEnd = FindMatchingBrace(content, classStart + classMatch.Length - 1);
|
||||
var classBody = content[classStart..Math.Min(classEnd, content.Length)];
|
||||
|
||||
foreach (Match methodMatch in methodPattern.Matches(classBody))
|
||||
{
|
||||
var methodName = methodMatch.Groups[1].Value;
|
||||
if (methodName is "if" or "for" or "while" or "switch" or "catch" or "constructor")
|
||||
continue;
|
||||
|
||||
var lineNumber = content[..(classStart + methodMatch.Index)].Count(c => c == '\n') + 1;
|
||||
var moduleBase = Path.GetFileNameWithoutExtension(file);
|
||||
|
||||
functions.Add(new JsFunctionInfo
|
||||
{
|
||||
NodeId = $"js:{packageName}/{moduleBase}.{className}.{methodName}",
|
||||
Name = methodName,
|
||||
FullName = $"{className}.{methodName}",
|
||||
Module = moduleBase,
|
||||
ClassName = className,
|
||||
Line = lineNumber,
|
||||
IsExported = false,
|
||||
IsAsync = methodMatch.Value.Contains("async"),
|
||||
Calls = []
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractRouteHandlers(string content, string file, string packageName, List<JsFunctionInfo> functions)
|
||||
{
|
||||
// Express/Fastify patterns
|
||||
var routePattern = new Regex(
|
||||
@"(?:app|router|server|fastify)\.(get|post|put|delete|patch|options|head)\s*\(\s*['""]([^'""]+)['""]",
|
||||
RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match match in routePattern.Matches(content))
|
||||
{
|
||||
var method = match.Groups[1].Value.ToUpperInvariant();
|
||||
var route = match.Groups[2].Value;
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
var moduleBase = Path.GetFileNameWithoutExtension(file);
|
||||
var handlerName = $"{method}_{SanitizeRoute(route)}";
|
||||
|
||||
functions.Add(new JsFunctionInfo
|
||||
{
|
||||
NodeId = $"js:{packageName}/{moduleBase}.{handlerName}",
|
||||
Name = handlerName,
|
||||
FullName = $"{moduleBase}.{handlerName}",
|
||||
Module = moduleBase,
|
||||
Line = lineNumber,
|
||||
IsExported = false,
|
||||
IsAsync = true,
|
||||
IsRouteHandler = true,
|
||||
HttpMethod = method,
|
||||
HttpRoute = route,
|
||||
Calls = []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static List<JsCallInfo> ExtractCallsFromFunction(string content, int startIndex)
|
||||
{
|
||||
// Simplified: would need proper AST parsing for accurate results
|
||||
return [];
|
||||
}
|
||||
|
||||
private static int FindMatchingBrace(string content, int openBraceIndex)
|
||||
{
|
||||
var depth = 1;
|
||||
for (var i = openBraceIndex + 1; i < content.Length && depth > 0; i++)
|
||||
{
|
||||
if (content[i] == '{') depth++;
|
||||
else if (content[i] == '}') depth--;
|
||||
if (depth == 0) return i;
|
||||
}
|
||||
return content.Length;
|
||||
}
|
||||
|
||||
private static string SanitizeRoute(string route)
|
||||
{
|
||||
return Regex.Replace(route, @"[/:{}*?]", "_").Trim('_');
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PackageInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Main { get; init; }
|
||||
public string? Type { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class JsFunctionInfo
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string FullName { get; init; }
|
||||
public required string Module { get; init; }
|
||||
public string? ClassName { get; init; }
|
||||
public int Line { get; init; }
|
||||
public bool IsExported { get; init; }
|
||||
public bool IsAsync { get; init; }
|
||||
public bool IsRouteHandler { get; init; }
|
||||
public string? HttpMethod { get; init; }
|
||||
public string? HttpRoute { get; init; }
|
||||
public List<JsCallInfo> Calls { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class JsCallInfo
|
||||
{
|
||||
public required string TargetNodeId { get; init; }
|
||||
public int Line { get; init; }
|
||||
public bool IsDynamic { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JsEntrypointClassifier.cs
|
||||
// Sprint: SPRINT_3610_0003_0001_nodejs_callgraph
|
||||
// Description: Classifies JavaScript/TypeScript functions as entrypoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.JavaScript;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies JavaScript/TypeScript functions as entrypoints based on framework patterns.
|
||||
/// Supports Express, Fastify, NestJS, Next.js, Koa, and other Node.js frameworks.
|
||||
/// </summary>
|
||||
internal sealed class JsEntrypointClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Module patterns that indicate entrypoints.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, EntrypointType> ModulePatterns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Express
|
||||
["express"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Fastify
|
||||
["fastify"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Koa
|
||||
["koa"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Hapi
|
||||
["@hapi/hapi"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Next.js
|
||||
["next"] = EntrypointType.HttpHandler,
|
||||
|
||||
// NestJS
|
||||
["@nestjs/core"] = EntrypointType.HttpHandler,
|
||||
["@nestjs/common"] = EntrypointType.HttpHandler,
|
||||
|
||||
// AWS Lambda
|
||||
["aws-lambda"] = EntrypointType.Lambda,
|
||||
|
||||
// Azure Functions
|
||||
["@azure/functions"] = EntrypointType.Lambda,
|
||||
|
||||
// Google Cloud Functions
|
||||
["@google-cloud/functions-framework"] = EntrypointType.Lambda,
|
||||
|
||||
// Commander CLI
|
||||
["commander"] = EntrypointType.CliCommand,
|
||||
|
||||
// Yargs CLI
|
||||
["yargs"] = EntrypointType.CliCommand,
|
||||
|
||||
// node-cron
|
||||
["node-cron"] = EntrypointType.ScheduledJob,
|
||||
|
||||
// agenda
|
||||
["agenda"] = EntrypointType.ScheduledJob,
|
||||
|
||||
// bull/bullmq
|
||||
["bull"] = EntrypointType.MessageHandler,
|
||||
["bullmq"] = EntrypointType.MessageHandler,
|
||||
|
||||
// amqplib
|
||||
["amqplib"] = EntrypointType.MessageHandler,
|
||||
|
||||
// kafkajs
|
||||
["kafkajs"] = EntrypointType.MessageHandler,
|
||||
|
||||
// Socket.io
|
||||
["socket.io"] = EntrypointType.WebSocketHandler,
|
||||
|
||||
// ws
|
||||
["ws"] = EntrypointType.WebSocketHandler,
|
||||
|
||||
// GraphQL
|
||||
["graphql"] = EntrypointType.HttpHandler,
|
||||
["apollo-server"] = EntrypointType.HttpHandler,
|
||||
["@apollo/server"] = EntrypointType.HttpHandler,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// NestJS decorator patterns.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> NestJsControllerDecorators = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Controller",
|
||||
"Get",
|
||||
"Post",
|
||||
"Put",
|
||||
"Delete",
|
||||
"Patch",
|
||||
"Options",
|
||||
"Head",
|
||||
"All"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a function based on its info.
|
||||
/// </summary>
|
||||
public EntrypointType? Classify(JsFunctionInfo func)
|
||||
{
|
||||
// Route handlers are always HTTP entrypoints
|
||||
if (func.IsRouteHandler)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Check for common entrypoint patterns
|
||||
var name = func.Name.ToLowerInvariant();
|
||||
|
||||
// AWS Lambda handlers
|
||||
if (name is "handler" or "main" or "lambdaHandler")
|
||||
{
|
||||
return EntrypointType.Lambda;
|
||||
}
|
||||
|
||||
// Express/Fastify middleware
|
||||
if (name.EndsWith("middleware", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Controller methods (NestJS pattern)
|
||||
if (func.ClassName?.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) == true && func.IsExported)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Service handlers (NestJS pattern)
|
||||
if (func.ClassName?.EndsWith("Handler", StringComparison.OrdinalIgnoreCase) == true && func.IsExported)
|
||||
{
|
||||
return EntrypointType.MessageHandler;
|
||||
}
|
||||
|
||||
// Resolver methods (GraphQL)
|
||||
if (func.ClassName?.EndsWith("Resolver", StringComparison.OrdinalIgnoreCase) == true && func.IsExported)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// CLI command handlers
|
||||
if (name is "run" or "execute" or "command" && func.Module.Contains("cli", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JsSinkMatcher.cs
|
||||
// Sprint: SPRINT_3610_0003_0001_nodejs_callgraph
|
||||
// Description: Matches JavaScript/TypeScript function calls to known security sinks.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.JavaScript;
|
||||
|
||||
/// <summary>
|
||||
/// Matches JavaScript function calls to known security-relevant sinks.
|
||||
/// </summary>
|
||||
public sealed class JsSinkMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of known JavaScript/Node.js sinks.
|
||||
/// </summary>
|
||||
private static readonly List<JsSinkPattern> SinkPatterns =
|
||||
[
|
||||
// Command injection
|
||||
new("child_process", "exec", SinkCategory.CmdExec),
|
||||
new("child_process", "execSync", SinkCategory.CmdExec),
|
||||
new("child_process", "spawn", SinkCategory.CmdExec),
|
||||
new("child_process", "spawnSync", SinkCategory.CmdExec),
|
||||
new("child_process", "execFile", SinkCategory.CmdExec),
|
||||
new("child_process", "fork", SinkCategory.CmdExec),
|
||||
new("shelljs", "exec", SinkCategory.CmdExec),
|
||||
new("execa", "execa", SinkCategory.CmdExec),
|
||||
|
||||
// SQL injection
|
||||
new("mysql", "query", SinkCategory.SqlRaw),
|
||||
new("mysql2", "query", SinkCategory.SqlRaw),
|
||||
new("pg", "query", SinkCategory.SqlRaw),
|
||||
new("better-sqlite3", "prepare", SinkCategory.SqlRaw),
|
||||
new("better-sqlite3", "exec", SinkCategory.SqlRaw),
|
||||
new("sequelize", "query", SinkCategory.SqlRaw),
|
||||
new("knex", "raw", SinkCategory.SqlRaw),
|
||||
new("typeorm", "query", SinkCategory.SqlRaw),
|
||||
|
||||
// NoSQL injection
|
||||
new("mongodb", "find", SinkCategory.SqlRaw),
|
||||
new("mongodb", "findOne", SinkCategory.SqlRaw),
|
||||
new("mongodb", "aggregate", SinkCategory.SqlRaw),
|
||||
new("mongoose", "find", SinkCategory.SqlRaw),
|
||||
new("mongoose", "findOne", SinkCategory.SqlRaw),
|
||||
|
||||
// Path traversal
|
||||
new("fs", "readFile", SinkCategory.PathTraversal),
|
||||
new("fs", "readFileSync", SinkCategory.PathTraversal),
|
||||
new("fs", "writeFile", SinkCategory.PathTraversal),
|
||||
new("fs", "writeFileSync", SinkCategory.PathTraversal),
|
||||
new("fs", "unlink", SinkCategory.PathTraversal),
|
||||
new("fs", "unlinkSync", SinkCategory.PathTraversal),
|
||||
new("fs", "readdir", SinkCategory.PathTraversal),
|
||||
new("fs", "readdirSync", SinkCategory.PathTraversal),
|
||||
new("fs", "mkdir", SinkCategory.PathTraversal),
|
||||
new("fs", "mkdirSync", SinkCategory.PathTraversal),
|
||||
new("fs", "rmdir", SinkCategory.PathTraversal),
|
||||
new("fs", "rmdirSync", SinkCategory.PathTraversal),
|
||||
new("fs", "rename", SinkCategory.PathTraversal),
|
||||
new("fs", "renameSync", SinkCategory.PathTraversal),
|
||||
new("fs/promises", "readFile", SinkCategory.PathTraversal),
|
||||
new("fs/promises", "writeFile", SinkCategory.PathTraversal),
|
||||
new("path", "join", SinkCategory.PathTraversal),
|
||||
new("path", "resolve", SinkCategory.PathTraversal),
|
||||
|
||||
// SSRF
|
||||
new("http", "request", SinkCategory.Ssrf),
|
||||
new("http", "get", SinkCategory.Ssrf),
|
||||
new("https", "request", SinkCategory.Ssrf),
|
||||
new("https", "get", SinkCategory.Ssrf),
|
||||
new("axios", "get", SinkCategory.Ssrf),
|
||||
new("axios", "post", SinkCategory.Ssrf),
|
||||
new("axios", "put", SinkCategory.Ssrf),
|
||||
new("axios", "delete", SinkCategory.Ssrf),
|
||||
new("axios", "request", SinkCategory.Ssrf),
|
||||
new("node-fetch", "fetch", SinkCategory.Ssrf),
|
||||
new("got", "got", SinkCategory.Ssrf),
|
||||
new("superagent", "get", SinkCategory.Ssrf),
|
||||
new("superagent", "post", SinkCategory.Ssrf),
|
||||
new("request", "request", SinkCategory.Ssrf),
|
||||
new("fetch", "fetch", SinkCategory.Ssrf),
|
||||
|
||||
// Deserialization
|
||||
new("js-yaml", "load", SinkCategory.UnsafeDeser),
|
||||
new("yaml", "parse", SinkCategory.UnsafeDeser),
|
||||
new("serialize-javascript", "deserialize", SinkCategory.UnsafeDeser),
|
||||
new("node-serialize", "unserialize", SinkCategory.UnsafeDeser),
|
||||
|
||||
// XSS / HTML injection
|
||||
new("innerHTML", "*", SinkCategory.TemplateInjection),
|
||||
new("document", "write", SinkCategory.TemplateInjection),
|
||||
new("document", "writeln", SinkCategory.TemplateInjection),
|
||||
new("dangerouslySetInnerHTML", "*", SinkCategory.TemplateInjection),
|
||||
|
||||
// Code injection / eval
|
||||
new("eval", "*", SinkCategory.CodeInjection),
|
||||
new("Function", "*", SinkCategory.CodeInjection),
|
||||
new("vm", "runInContext", SinkCategory.CodeInjection),
|
||||
new("vm", "runInNewContext", SinkCategory.CodeInjection),
|
||||
new("vm", "runInThisContext", SinkCategory.CodeInjection),
|
||||
new("vm", "compileFunction", SinkCategory.CodeInjection),
|
||||
|
||||
// Regex DoS
|
||||
new("RegExp", "*", SinkCategory.CmdExec),
|
||||
|
||||
// Open redirect
|
||||
new("redirect", "*", SinkCategory.OpenRedirect),
|
||||
new("res", "redirect", SinkCategory.OpenRedirect),
|
||||
|
||||
// Template injection
|
||||
new("ejs", "render", SinkCategory.TemplateInjection),
|
||||
new("pug", "render", SinkCategory.TemplateInjection),
|
||||
new("handlebars", "compile", SinkCategory.TemplateInjection),
|
||||
new("mustache", "render", SinkCategory.TemplateInjection),
|
||||
new("nunjucks", "render", SinkCategory.TemplateInjection),
|
||||
|
||||
// Log injection
|
||||
new("console", "log", SinkCategory.LogInjection),
|
||||
new("console", "error", SinkCategory.LogInjection),
|
||||
new("console", "warn", SinkCategory.LogInjection),
|
||||
new("winston", "log", SinkCategory.LogInjection),
|
||||
new("bunyan", "info", SinkCategory.LogInjection),
|
||||
new("pino", "info", SinkCategory.LogInjection),
|
||||
|
||||
// Crypto weaknesses
|
||||
new("crypto", "createHash", SinkCategory.CryptoWeak),
|
||||
new("crypto", "createCipher", SinkCategory.CryptoWeak),
|
||||
new("crypto", "createCipheriv", SinkCategory.CryptoWeak),
|
||||
new("Math", "random", SinkCategory.CryptoWeak),
|
||||
|
||||
// Prototype pollution
|
||||
new("Object", "assign", SinkCategory.CodeInjection),
|
||||
new("lodash", "merge", SinkCategory.CodeInjection),
|
||||
new("lodash", "defaultsDeep", SinkCategory.CodeInjection),
|
||||
new("lodash", "set", SinkCategory.CodeInjection),
|
||||
new("lodash", "setWith", SinkCategory.CodeInjection),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Matches a function call to a sink category.
|
||||
/// </summary>
|
||||
/// <param name="module">Module name.</param>
|
||||
/// <param name="funcName">Function name.</param>
|
||||
/// <returns>Sink category if matched; otherwise, null.</returns>
|
||||
public SinkCategory? Match(string module, string funcName)
|
||||
{
|
||||
foreach (var pattern in SinkPatterns)
|
||||
{
|
||||
if (pattern.Matches(module, funcName))
|
||||
{
|
||||
return pattern.Category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for matching JavaScript sinks.
|
||||
/// </summary>
|
||||
internal sealed record JsSinkPattern(string Module, string Function, SinkCategory Category)
|
||||
{
|
||||
public bool Matches(string module, string funcName)
|
||||
{
|
||||
// Check module match
|
||||
var moduleMatch = string.Equals(module, Module, StringComparison.OrdinalIgnoreCase) ||
|
||||
module.EndsWith("/" + Module, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!moduleMatch && !string.Equals(funcName, Module, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check function match (wildcard support)
|
||||
if (Function == "*")
|
||||
{
|
||||
return moduleMatch || string.Equals(funcName, Module, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(funcName, Function, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BabelResultParser.cs
|
||||
// Sprint: SPRINT_3610_0003_0001_nodejs_callgraph
|
||||
// Description: Parses JSON output from stella-callgraph-node tool.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Node;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the JSON output from the stella-callgraph-node tool.
|
||||
/// </summary>
|
||||
public static class BabelResultParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses the JSON output from stella-callgraph-node.
|
||||
/// </summary>
|
||||
/// <param name="json">The JSON string to parse.</param>
|
||||
/// <returns>The parsed call graph result.</returns>
|
||||
public static JsCallGraphResult Parse(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
|
||||
return JsonSerializer.Deserialize<JsCallGraphResult>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse JavaScript call graph result");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the JSON output from stella-callgraph-node asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="stream">The stream containing JSON.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The parsed call graph result.</returns>
|
||||
public static async Task<JsCallGraphResult> ParseAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
return await JsonSerializer.DeserializeAsync<JsCallGraphResult>(stream, JsonOptions, cancellationToken)
|
||||
?? throw new InvalidOperationException("Failed to parse JavaScript call graph result");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses NDJSON (newline-delimited JSON) output.
|
||||
/// </summary>
|
||||
/// <param name="ndjson">The NDJSON string to parse.</param>
|
||||
/// <returns>The parsed call graph result.</returns>
|
||||
public static JsCallGraphResult ParseNdjson(string ndjson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ndjson);
|
||||
|
||||
// NDJSON format: each line is a complete JSON object
|
||||
// For our tool, we output the entire result as a single line
|
||||
var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Empty NDJSON input");
|
||||
}
|
||||
|
||||
// Take the last non-empty line (the result)
|
||||
return Parse(lines[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Root result from stella-callgraph-node.
|
||||
/// </summary>
|
||||
public sealed record JsCallGraphResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The npm package/module name.
|
||||
/// </summary>
|
||||
public required string Module { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The package version if available.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All function nodes in the call graph.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JsNodeInfo> Nodes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// All call edges in the graph.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JsEdgeInfo> Edges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Detected entrypoints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JsEntrypointInfo> Entrypoints { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A function node from the JavaScript call graph.
|
||||
/// </summary>
|
||||
public sealed record JsNodeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique symbol ID (e.g., js:my-package/module.function).
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
public required string Package { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function signature.
|
||||
/// </summary>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source position.
|
||||
/// </summary>
|
||||
public JsPositionInfo? Position { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Visibility (public/private).
|
||||
/// </summary>
|
||||
public string Visibility { get; init; } = "private";
|
||||
|
||||
/// <summary>
|
||||
/// Decorators or annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Annotations { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A call edge from the JavaScript call graph.
|
||||
/// </summary>
|
||||
public sealed record JsEdgeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Source node ID.
|
||||
/// </summary>
|
||||
public required string From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target node ID.
|
||||
/// </summary>
|
||||
public required string To { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call kind (direct, dynamic, callback).
|
||||
/// </summary>
|
||||
public string Kind { get; init; } = "direct";
|
||||
|
||||
/// <summary>
|
||||
/// Call site position.
|
||||
/// </summary>
|
||||
public JsPositionInfo? Site { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source code position.
|
||||
/// </summary>
|
||||
public sealed record JsPositionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Source file path (relative to project root).
|
||||
/// </summary>
|
||||
public string? File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number (1-based).
|
||||
/// </summary>
|
||||
public int Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Column number (0-based).
|
||||
/// </summary>
|
||||
public int Column { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An entrypoint from the JavaScript call graph.
|
||||
/// </summary>
|
||||
public sealed record JsEntrypointInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Node ID of the entrypoint.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint type (http_handler, lambda, websocket_handler, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP route if applicable.
|
||||
/// </summary>
|
||||
public string? Route { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP method if applicable.
|
||||
/// </summary>
|
||||
public string? Method { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PhpCallGraphExtractor.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: PHP call graph extractor using AST-based analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Php;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call graphs from PHP source code.
|
||||
/// Supports Laravel, Symfony, and Slim frameworks.
|
||||
/// </summary>
|
||||
public sealed class PhpCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly ILogger<PhpCallGraphExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly PhpEntrypointClassifier _entrypointClassifier;
|
||||
private readonly PhpSinkMatcher _sinkMatcher;
|
||||
|
||||
public PhpCallGraphExtractor(
|
||||
ILogger<PhpCallGraphExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_entrypointClassifier = new PhpEntrypointClassifier();
|
||||
_sinkMatcher = new PhpSinkMatcher();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Language => "php";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var targetPath = Path.GetFullPath(request.TargetPath);
|
||||
var projectRoot = FindPhpProjectRoot(targetPath);
|
||||
|
||||
if (projectRoot is null)
|
||||
{
|
||||
throw new FileNotFoundException($"No PHP project found at or above: {targetPath}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting PHP call graph extraction for project at {Path}", projectRoot);
|
||||
|
||||
var projectInfo = await ReadProjectInfoAsync(projectRoot, cancellationToken);
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
|
||||
var phpFiles = GetPhpFiles(projectRoot);
|
||||
_logger.LogDebug("Found {Count} PHP files", phpFiles.Count);
|
||||
|
||||
foreach (var phpFile in phpFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(phpFile, cancellationToken);
|
||||
var relativePath = Path.GetRelativePath(projectRoot, phpFile);
|
||||
|
||||
var functions = ExtractFunctions(content, relativePath, projectInfo.Name);
|
||||
|
||||
foreach (var func in functions)
|
||||
{
|
||||
var entrypointType = _entrypointClassifier.Classify(func);
|
||||
var sinkCategory = _sinkMatcher.Match(func.Namespace, func.Name);
|
||||
|
||||
var node = new CallGraphNode(
|
||||
NodeId: func.NodeId,
|
||||
Symbol: func.FullName,
|
||||
File: relativePath,
|
||||
Line: func.Line,
|
||||
Package: projectInfo.Name,
|
||||
Visibility: func.IsPublic ? Visibility.Public : Visibility.Private,
|
||||
IsEntrypoint: entrypointType.HasValue,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: sinkCategory.HasValue,
|
||||
SinkCategory: sinkCategory);
|
||||
|
||||
nodesById.TryAdd(node.NodeId, node);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse {File}", phpFile);
|
||||
}
|
||||
}
|
||||
|
||||
return BuildSnapshot(request.ScanId, nodesById, edges);
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildSnapshot(
|
||||
string scanId,
|
||||
Dictionary<string, CallGraphNode> nodesById,
|
||||
HashSet<CallGraphEdge> edges)
|
||||
{
|
||||
var nodes = nodesById.Values
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypointIds = nodes
|
||||
.Where(n => n.IsEntrypoint)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinkIds = nodes
|
||||
.Where(n => n.IsSink)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: nodes,
|
||||
Edges: orderedEdges,
|
||||
EntrypointIds: entrypointIds,
|
||||
SinkIds: sinkIds);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
|
||||
_logger.LogInformation(
|
||||
"PHP call graph extracted: {Nodes} nodes, {Edges} edges, {Entrypoints} entrypoints, {Sinks} sinks",
|
||||
nodes.Length, orderedEdges.Length, entrypointIds.Length, sinkIds.Length);
|
||||
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private static string? FindPhpProjectRoot(string startPath)
|
||||
{
|
||||
var current = startPath;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
if (File.Exists(Path.Combine(current, "composer.json")) ||
|
||||
File.Exists(Path.Combine(current, "artisan")) ||
|
||||
Directory.Exists(Path.Combine(current, "app")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
current = Path.GetDirectoryName(current);
|
||||
}
|
||||
return startPath;
|
||||
}
|
||||
|
||||
private static async Task<PhpProjectInfo> ReadProjectInfoAsync(string projectRoot, CancellationToken ct)
|
||||
{
|
||||
var composerPath = Path.Combine(projectRoot, "composer.json");
|
||||
var name = Path.GetFileName(projectRoot) ?? "unknown";
|
||||
|
||||
if (File.Exists(composerPath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(composerPath, ct);
|
||||
var nameMatch = Regex.Match(content, @"""name"":\s*""([^""]+)""");
|
||||
if (nameMatch.Success)
|
||||
{
|
||||
name = nameMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
// Detect framework
|
||||
if (content.Contains("laravel/framework"))
|
||||
{
|
||||
return new PhpProjectInfo { Name = name, Framework = "laravel" };
|
||||
}
|
||||
if (content.Contains("symfony/symfony") || content.Contains("symfony/framework-bundle"))
|
||||
{
|
||||
return new PhpProjectInfo { Name = name, Framework = "symfony" };
|
||||
}
|
||||
if (content.Contains("slim/slim"))
|
||||
{
|
||||
return new PhpProjectInfo { Name = name, Framework = "slim" };
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpProjectInfo { Name = name };
|
||||
}
|
||||
|
||||
private static List<string> GetPhpFiles(string projectRoot)
|
||||
{
|
||||
var excludeDirs = new[] { "vendor", "node_modules", "storage", "cache", "tests" };
|
||||
|
||||
return Directory.EnumerateFiles(projectRoot, "*.php", SearchOption.AllDirectories)
|
||||
.Where(f => !excludeDirs.Any(d => f.Contains(Path.DirectorySeparatorChar + d + Path.DirectorySeparatorChar)))
|
||||
.Where(f => !f.EndsWith("Test.php") && !f.EndsWith("_test.php"))
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<PhpFunctionInfo> ExtractFunctions(string content, string file, string projectName)
|
||||
{
|
||||
var functions = new List<PhpFunctionInfo>();
|
||||
|
||||
// Extract namespace
|
||||
var namespaceMatch = Regex.Match(content, @"namespace\s+([^;]+);");
|
||||
var ns = namespaceMatch.Success ? namespaceMatch.Groups[1].Value : "";
|
||||
|
||||
// Extract class with methods
|
||||
ExtractClassMethods(content, file, projectName, ns, functions);
|
||||
|
||||
// Extract standalone functions
|
||||
ExtractStandaloneFunctions(content, file, projectName, ns, functions);
|
||||
|
||||
// Extract Laravel routes
|
||||
ExtractLaravelRoutes(content, file, projectName, functions);
|
||||
|
||||
// Extract Symfony annotations
|
||||
ExtractSymfonyRoutes(content, file, projectName, ns, functions);
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
private static void ExtractClassMethods(string content, string file, string projectName, string ns, List<PhpFunctionInfo> functions)
|
||||
{
|
||||
var classPattern = new Regex(@"class\s+(\w+)(?:\s+extends\s+(\w+))?(?:\s+implements\s+[^{]+)?", RegexOptions.Multiline);
|
||||
var methodPattern = new Regex(
|
||||
@"(public|protected|private)\s+(?:static\s+)?function\s+(\w+)\s*\(([^)]*)\)",
|
||||
RegexOptions.Multiline);
|
||||
|
||||
foreach (Match classMatch in classPattern.Matches(content))
|
||||
{
|
||||
var className = classMatch.Groups[1].Value;
|
||||
var parentClass = classMatch.Groups[2].Value;
|
||||
var classStart = classMatch.Index;
|
||||
|
||||
// Find class body
|
||||
var nextClass = classPattern.Match(content, classStart + classMatch.Length);
|
||||
var classEnd = nextClass.Success ? nextClass.Index : content.Length;
|
||||
var classBody = content[classStart..classEnd];
|
||||
|
||||
foreach (Match methodMatch in methodPattern.Matches(classBody))
|
||||
{
|
||||
var visibility = methodMatch.Groups[1].Value;
|
||||
var methodName = methodMatch.Groups[2].Value;
|
||||
var lineNumber = content[..(classStart + methodMatch.Index)].Count(c => c == '\n') + 1;
|
||||
|
||||
var fullName = string.IsNullOrEmpty(ns)
|
||||
? $"{className}::{methodName}"
|
||||
: $"{ns}\\{className}::{methodName}";
|
||||
|
||||
var nodeId = $"php:{projectName}/{fullName.Replace('\\', '/')}";
|
||||
|
||||
functions.Add(new PhpFunctionInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = methodName,
|
||||
FullName = fullName,
|
||||
Namespace = ns,
|
||||
ClassName = className,
|
||||
ParentClass = parentClass,
|
||||
Line = lineNumber,
|
||||
IsPublic = visibility == "public",
|
||||
Annotations = ExtractAnnotations(classBody, methodMatch.Index)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractStandaloneFunctions(string content, string file, string projectName, string ns, List<PhpFunctionInfo> functions)
|
||||
{
|
||||
var funcPattern = new Regex(@"^function\s+(\w+)\s*\(([^)]*)\)", RegexOptions.Multiline);
|
||||
|
||||
foreach (Match match in funcPattern.Matches(content))
|
||||
{
|
||||
var funcName = match.Groups[1].Value;
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
|
||||
var fullName = string.IsNullOrEmpty(ns) ? funcName : $"{ns}\\{funcName}";
|
||||
var nodeId = $"php:{projectName}/{fullName.Replace('\\', '/')}";
|
||||
|
||||
functions.Add(new PhpFunctionInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = funcName,
|
||||
FullName = fullName,
|
||||
Namespace = ns,
|
||||
Line = lineNumber,
|
||||
IsPublic = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractLaravelRoutes(string content, string file, string projectName, List<PhpFunctionInfo> functions)
|
||||
{
|
||||
// Laravel route patterns
|
||||
var routePattern = new Regex(
|
||||
@"Route::(get|post|put|patch|delete|any)\s*\(\s*['""]([^'""]+)['""]",
|
||||
RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match match in routePattern.Matches(content))
|
||||
{
|
||||
var method = match.Groups[1].Value.ToUpperInvariant();
|
||||
var route = match.Groups[2].Value;
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
|
||||
// Look for controller action reference
|
||||
var actionMatch = Regex.Match(content[(match.Index + match.Length)..], @"\[(\w+Controller)::class,\s*['""](\w+)['""]\]");
|
||||
if (actionMatch.Success)
|
||||
{
|
||||
var controller = actionMatch.Groups[1].Value;
|
||||
var action = actionMatch.Groups[2].Value;
|
||||
var nodeId = $"php:{projectName}/{controller}/{action}";
|
||||
|
||||
functions.Add(new PhpFunctionInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = action,
|
||||
FullName = $"{controller}::{action}",
|
||||
Namespace = "",
|
||||
ClassName = controller,
|
||||
Line = lineNumber,
|
||||
IsPublic = true,
|
||||
IsRouteHandler = true,
|
||||
HttpMethod = method,
|
||||
HttpRoute = route
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractSymfonyRoutes(string content, string file, string projectName, string ns, List<PhpFunctionInfo> functions)
|
||||
{
|
||||
// Symfony Route annotations
|
||||
var routeAnnotation = new Regex(
|
||||
@"#\[Route\s*\(['""]([^'""]+)['""](?:.*methods:\s*\[([^\]]+)\])?",
|
||||
RegexOptions.Multiline);
|
||||
|
||||
foreach (Match match in routeAnnotation.Matches(content))
|
||||
{
|
||||
var route = match.Groups[1].Value;
|
||||
var methods = match.Groups[2].Value;
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
|
||||
// Find the method after the annotation
|
||||
var methodMatch = Regex.Match(content[(match.Index + match.Length)..], @"public\s+function\s+(\w+)");
|
||||
if (methodMatch.Success)
|
||||
{
|
||||
var methodName = methodMatch.Groups[1].Value;
|
||||
var nodeId = $"php:{projectName}/{ns.Replace('\\', '/')}/{methodName}";
|
||||
|
||||
functions.Add(new PhpFunctionInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = methodName,
|
||||
FullName = $"{ns}\\{methodName}",
|
||||
Namespace = ns,
|
||||
Line = lineNumber,
|
||||
IsPublic = true,
|
||||
IsRouteHandler = true,
|
||||
HttpMethod = string.IsNullOrEmpty(methods) ? "GET" : methods.Trim('\'', '"', ' ').ToUpperInvariant(),
|
||||
HttpRoute = route,
|
||||
Annotations = ["#[Route]"]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ExtractAnnotations(string content, int methodIndex)
|
||||
{
|
||||
var annotations = new List<string>();
|
||||
|
||||
// Look backwards for annotations/attributes
|
||||
var beforeMethod = content[..methodIndex];
|
||||
var lines = beforeMethod.Split('\n');
|
||||
|
||||
for (var i = lines.Length - 1; i >= 0 && i >= lines.Length - 10; i--)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (line.StartsWith("#[") || line.StartsWith("@"))
|
||||
{
|
||||
annotations.Insert(0, line);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(line) && !line.StartsWith("//") && !line.StartsWith("/*") && !line.StartsWith("*"))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PhpProjectInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Framework { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PhpFunctionInfo
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string FullName { get; init; }
|
||||
public required string Namespace { get; init; }
|
||||
public string? ClassName { get; init; }
|
||||
public string? ParentClass { get; init; }
|
||||
public int Line { get; init; }
|
||||
public bool IsPublic { get; init; }
|
||||
public bool IsRouteHandler { get; init; }
|
||||
public string? HttpMethod { get; init; }
|
||||
public string? HttpRoute { get; init; }
|
||||
public List<string> Annotations { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PhpEntrypointClassifier.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Classifies PHP functions as entrypoints based on framework patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Php;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies PHP functions as entrypoints based on framework patterns.
|
||||
/// Supports Laravel, Symfony, and Slim frameworks.
|
||||
/// </summary>
|
||||
internal sealed class PhpEntrypointClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Laravel controller action method names.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> LaravelActionNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"index", "show", "create", "store", "edit", "update", "destroy"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Symfony route annotations.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> SymfonyRouteAnnotations = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"#[Route",
|
||||
"@Route",
|
||||
"#[Get",
|
||||
"#[Post",
|
||||
"#[Put",
|
||||
"#[Delete",
|
||||
"#[Patch"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Controller parent classes.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ControllerParentClasses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Controller",
|
||||
"BaseController",
|
||||
"AbstractController",
|
||||
"ResourceController"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a function based on its info.
|
||||
/// </summary>
|
||||
public EntrypointType? Classify(PhpFunctionInfo func)
|
||||
{
|
||||
// Route handlers are HTTP entrypoints
|
||||
if (func.IsRouteHandler)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Check for Symfony route annotations
|
||||
foreach (var annotation in func.Annotations)
|
||||
{
|
||||
foreach (var routeAnnotation in SymfonyRouteAnnotations)
|
||||
{
|
||||
if (annotation.StartsWith(routeAnnotation, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Laravel controller
|
||||
if (func.ClassName?.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
// Standard resource actions
|
||||
if (LaravelActionNames.Contains(func.Name))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Public methods in controllers are typically actions
|
||||
if (func.IsPublic && !func.Name.StartsWith("_") && !func.Name.StartsWith("__"))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Check parent class for controller inheritance
|
||||
if (!string.IsNullOrEmpty(func.ParentClass) &&
|
||||
ControllerParentClasses.Contains(func.ParentClass))
|
||||
{
|
||||
if (func.IsPublic)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Laravel Job classes
|
||||
if (func.ClassName?.EndsWith("Job", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
if (func.Name == "handle")
|
||||
{
|
||||
return EntrypointType.MessageHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Laravel Event Listeners
|
||||
if (func.ClassName?.EndsWith("Listener", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
if (func.Name == "handle")
|
||||
{
|
||||
return EntrypointType.MessageHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Symfony Commands
|
||||
if (func.ClassName?.EndsWith("Command", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
if (func.Name is "execute" or "configure")
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
}
|
||||
|
||||
// Laravel Artisan Commands
|
||||
if (func.Annotations.Any(a => a.Contains("Command", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (func.Name == "handle")
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled tasks
|
||||
if (func.ClassName?.Contains("Schedule", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
func.Annotations.Any(a => a.Contains("Schedule", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return EntrypointType.ScheduledJob;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PhpSinkMatcher.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Matches PHP function calls to known security sinks.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Php;
|
||||
|
||||
/// <summary>
|
||||
/// Matches PHP function calls to known security-relevant sinks.
|
||||
/// </summary>
|
||||
public sealed class PhpSinkMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of known PHP sinks.
|
||||
/// </summary>
|
||||
private static readonly List<PhpSinkPattern> SinkPatterns =
|
||||
[
|
||||
// Command injection
|
||||
new("", "exec", SinkCategory.CmdExec),
|
||||
new("", "shell_exec", SinkCategory.CmdExec),
|
||||
new("", "system", SinkCategory.CmdExec),
|
||||
new("", "passthru", SinkCategory.CmdExec),
|
||||
new("", "popen", SinkCategory.CmdExec),
|
||||
new("", "proc_open", SinkCategory.CmdExec),
|
||||
new("", "pcntl_exec", SinkCategory.CmdExec),
|
||||
new("", "backtick", SinkCategory.CmdExec),
|
||||
|
||||
// SQL injection
|
||||
new("mysqli", "query", SinkCategory.SqlRaw),
|
||||
new("mysqli", "multi_query", SinkCategory.SqlRaw),
|
||||
new("mysqli", "real_query", SinkCategory.SqlRaw),
|
||||
new("PDO", "query", SinkCategory.SqlRaw),
|
||||
new("PDO", "exec", SinkCategory.SqlRaw),
|
||||
new("", "mysql_query", SinkCategory.SqlRaw),
|
||||
new("", "mysql_unbuffered_query", SinkCategory.SqlRaw),
|
||||
new("", "pg_query", SinkCategory.SqlRaw),
|
||||
new("", "pg_send_query", SinkCategory.SqlRaw),
|
||||
new("DB", "raw", SinkCategory.SqlRaw),
|
||||
new("DB", "select", SinkCategory.SqlRaw),
|
||||
new("DB", "statement", SinkCategory.SqlRaw),
|
||||
|
||||
// Path traversal
|
||||
new("", "file_get_contents", SinkCategory.PathTraversal),
|
||||
new("", "file_put_contents", SinkCategory.PathTraversal),
|
||||
new("", "fopen", SinkCategory.PathTraversal),
|
||||
new("", "readfile", SinkCategory.PathTraversal),
|
||||
new("", "file", SinkCategory.PathTraversal),
|
||||
new("", "include", SinkCategory.PathTraversal),
|
||||
new("", "include_once", SinkCategory.PathTraversal),
|
||||
new("", "require", SinkCategory.PathTraversal),
|
||||
new("", "require_once", SinkCategory.PathTraversal),
|
||||
new("", "unlink", SinkCategory.PathTraversal),
|
||||
new("", "rmdir", SinkCategory.PathTraversal),
|
||||
new("", "mkdir", SinkCategory.PathTraversal),
|
||||
new("", "rename", SinkCategory.PathTraversal),
|
||||
new("", "copy", SinkCategory.PathTraversal),
|
||||
new("", "move_uploaded_file", SinkCategory.PathTraversal),
|
||||
|
||||
// SSRF
|
||||
new("", "curl_exec", SinkCategory.Ssrf),
|
||||
new("", "curl_multi_exec", SinkCategory.Ssrf),
|
||||
new("", "file_get_contents", SinkCategory.Ssrf),
|
||||
new("", "fopen", SinkCategory.Ssrf),
|
||||
new("", "fsockopen", SinkCategory.Ssrf),
|
||||
new("", "pfsockopen", SinkCategory.Ssrf),
|
||||
new("", "get_headers", SinkCategory.Ssrf),
|
||||
new("GuzzleHttp\\Client", "request", SinkCategory.Ssrf),
|
||||
new("GuzzleHttp\\Client", "get", SinkCategory.Ssrf),
|
||||
new("GuzzleHttp\\Client", "post", SinkCategory.Ssrf),
|
||||
new("Http", "get", SinkCategory.Ssrf),
|
||||
new("Http", "post", SinkCategory.Ssrf),
|
||||
|
||||
// Deserialization
|
||||
new("", "unserialize", SinkCategory.UnsafeDeser),
|
||||
new("", "maybe_unserialize", SinkCategory.UnsafeDeser),
|
||||
new("yaml", "parse", SinkCategory.UnsafeDeser),
|
||||
|
||||
// XXE
|
||||
new("SimpleXMLElement", "__construct", SinkCategory.XxeInjection),
|
||||
new("", "simplexml_load_string", SinkCategory.XxeInjection),
|
||||
new("", "simplexml_load_file", SinkCategory.XxeInjection),
|
||||
new("DOMDocument", "loadXML", SinkCategory.XxeInjection),
|
||||
new("DOMDocument", "load", SinkCategory.XxeInjection),
|
||||
new("XMLReader", "open", SinkCategory.XxeInjection),
|
||||
new("XMLReader", "xml", SinkCategory.XxeInjection),
|
||||
|
||||
// Code injection
|
||||
new("", "eval", SinkCategory.CodeInjection),
|
||||
new("", "create_function", SinkCategory.CodeInjection),
|
||||
new("", "assert", SinkCategory.CodeInjection),
|
||||
new("", "preg_replace", SinkCategory.CodeInjection),
|
||||
new("", "call_user_func", SinkCategory.CodeInjection),
|
||||
new("", "call_user_func_array", SinkCategory.CodeInjection),
|
||||
new("ReflectionFunction", "invoke", SinkCategory.CodeInjection),
|
||||
new("ReflectionMethod", "invoke", SinkCategory.CodeInjection),
|
||||
|
||||
// Template injection
|
||||
new("", "extract", SinkCategory.TemplateInjection),
|
||||
new("Twig\\Environment", "render", SinkCategory.TemplateInjection),
|
||||
new("Blade", "render", SinkCategory.TemplateInjection),
|
||||
|
||||
// Open redirect
|
||||
new("", "header", SinkCategory.OpenRedirect),
|
||||
new("Response", "redirect", SinkCategory.OpenRedirect),
|
||||
new("", "redirect", SinkCategory.OpenRedirect),
|
||||
|
||||
// Log injection
|
||||
new("Log", "info", SinkCategory.LogInjection),
|
||||
new("Log", "warning", SinkCategory.LogInjection),
|
||||
new("Log", "error", SinkCategory.LogInjection),
|
||||
new("Logger", "info", SinkCategory.LogInjection),
|
||||
new("Logger", "error", SinkCategory.LogInjection),
|
||||
new("", "error_log", SinkCategory.LogInjection),
|
||||
|
||||
// LDAP injection
|
||||
new("", "ldap_search", SinkCategory.LdapInjection),
|
||||
new("", "ldap_read", SinkCategory.LdapInjection),
|
||||
new("", "ldap_list", SinkCategory.LdapInjection),
|
||||
new("", "ldap_bind", SinkCategory.LdapInjection),
|
||||
|
||||
// XPath injection
|
||||
new("DOMXPath", "query", SinkCategory.XPathInjection),
|
||||
new("SimpleXMLElement", "xpath", SinkCategory.XPathInjection),
|
||||
|
||||
// Crypto weaknesses
|
||||
new("", "md5", SinkCategory.CryptoWeak),
|
||||
new("", "sha1", SinkCategory.CryptoWeak),
|
||||
new("", "crypt", SinkCategory.CryptoWeak),
|
||||
new("", "rand", SinkCategory.CryptoWeak),
|
||||
new("", "mt_rand", SinkCategory.CryptoWeak),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Matches a function call to a sink category.
|
||||
/// </summary>
|
||||
public SinkCategory? Match(string ns, string funcName)
|
||||
{
|
||||
foreach (var pattern in SinkPatterns)
|
||||
{
|
||||
if (pattern.Matches(ns, funcName))
|
||||
{
|
||||
return pattern.Category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for matching PHP sinks.
|
||||
/// </summary>
|
||||
internal sealed record PhpSinkPattern(string Class, string Function, SinkCategory Category)
|
||||
{
|
||||
public bool Matches(string ns, string funcName)
|
||||
{
|
||||
// Check class match (empty class means global function)
|
||||
if (!string.IsNullOrEmpty(Class))
|
||||
{
|
||||
var classMatch = ns.EndsWith(Class, StringComparison.OrdinalIgnoreCase) ||
|
||||
ns.Contains(Class, StringComparison.OrdinalIgnoreCase);
|
||||
if (!classMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check function match
|
||||
return string.Equals(funcName, Function, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PythonCallGraphExtractor.cs
|
||||
// Sprint: SPRINT_3610_0004_0001_python_callgraph
|
||||
// Description: Python call graph extractor using AST analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Python;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call graphs from Python source code using AST-based analysis.
|
||||
/// Supports Django, Flask, FastAPI, Celery, and other frameworks.
|
||||
/// </summary>
|
||||
public sealed class PythonCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly ILogger<PythonCallGraphExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly PythonEntrypointClassifier _entrypointClassifier;
|
||||
private readonly PythonSinkMatcher _sinkMatcher;
|
||||
|
||||
public PythonCallGraphExtractor(
|
||||
ILogger<PythonCallGraphExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_entrypointClassifier = new PythonEntrypointClassifier();
|
||||
_sinkMatcher = new PythonSinkMatcher();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Language => "python";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var targetPath = Path.GetFullPath(request.TargetPath);
|
||||
var projectRoot = FindPythonProjectRoot(targetPath);
|
||||
|
||||
if (projectRoot is null)
|
||||
{
|
||||
throw new FileNotFoundException($"No Python project found at or above: {targetPath}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting Python call graph extraction for project at {Path}", projectRoot);
|
||||
|
||||
// Read project metadata
|
||||
var projectInfo = await ReadProjectInfoAsync(projectRoot, cancellationToken);
|
||||
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
|
||||
// Find all Python files
|
||||
var pythonFiles = GetPythonFiles(projectRoot);
|
||||
_logger.LogDebug("Found {Count} Python files", pythonFiles.Count);
|
||||
|
||||
foreach (var pythonFile in pythonFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(pythonFile, cancellationToken);
|
||||
var relativePath = Path.GetRelativePath(projectRoot, pythonFile);
|
||||
var moduleName = GetModuleName(relativePath);
|
||||
|
||||
var functions = ExtractFunctions(content, relativePath, moduleName, projectInfo.Name);
|
||||
|
||||
foreach (var func in functions)
|
||||
{
|
||||
var entrypointType = _entrypointClassifier.Classify(func);
|
||||
var sinkCategory = _sinkMatcher.Match(func.Module, func.Name);
|
||||
|
||||
var node = new CallGraphNode(
|
||||
NodeId: func.NodeId,
|
||||
Symbol: func.FullName,
|
||||
File: relativePath,
|
||||
Line: func.Line,
|
||||
Package: projectInfo.Name,
|
||||
Visibility: func.IsPublic ? Visibility.Public : Visibility.Private,
|
||||
IsEntrypoint: entrypointType.HasValue,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: sinkCategory.HasValue,
|
||||
SinkCategory: sinkCategory);
|
||||
|
||||
nodesById.TryAdd(node.NodeId, node);
|
||||
|
||||
// Extract function calls
|
||||
foreach (var call in func.Calls)
|
||||
{
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: func.NodeId,
|
||||
TargetId: call.TargetNodeId,
|
||||
CallKind: CallKind.Direct,
|
||||
CallSite: $"{relativePath}:{call.Line}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse {File}", pythonFile);
|
||||
}
|
||||
}
|
||||
|
||||
return BuildSnapshot(request.ScanId, nodesById, edges);
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildSnapshot(
|
||||
string scanId,
|
||||
Dictionary<string, CallGraphNode> nodesById,
|
||||
HashSet<CallGraphEdge> edges)
|
||||
{
|
||||
var nodes = nodesById.Values
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypointIds = nodes
|
||||
.Where(n => n.IsEntrypoint)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinkIds = nodes
|
||||
.Where(n => n.IsSink)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: nodes,
|
||||
Edges: orderedEdges,
|
||||
EntrypointIds: entrypointIds,
|
||||
SinkIds: sinkIds);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Python call graph extracted: {Nodes} nodes, {Edges} edges, {Entrypoints} entrypoints, {Sinks} sinks",
|
||||
nodes.Length, orderedEdges.Length, entrypointIds.Length, sinkIds.Length);
|
||||
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private static string? FindPythonProjectRoot(string startPath)
|
||||
{
|
||||
var current = startPath;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
// Check for common Python project markers
|
||||
if (File.Exists(Path.Combine(current, "pyproject.toml")) ||
|
||||
File.Exists(Path.Combine(current, "setup.py")) ||
|
||||
File.Exists(Path.Combine(current, "setup.cfg")) ||
|
||||
File.Exists(Path.Combine(current, "requirements.txt")) ||
|
||||
File.Exists(Path.Combine(current, "Pipfile")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
current = Path.GetDirectoryName(current);
|
||||
}
|
||||
return startPath; // Fallback to start path
|
||||
}
|
||||
|
||||
private static async Task<PythonProjectInfo> ReadProjectInfoAsync(string projectRoot, CancellationToken ct)
|
||||
{
|
||||
// Try pyproject.toml first
|
||||
var pyprojectPath = Path.Combine(projectRoot, "pyproject.toml");
|
||||
if (File.Exists(pyprojectPath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(pyprojectPath, ct);
|
||||
var nameMatch = Regex.Match(content, @"name\s*=\s*[""']([^""']+)[""']");
|
||||
if (nameMatch.Success)
|
||||
{
|
||||
return new PythonProjectInfo { Name = nameMatch.Groups[1].Value };
|
||||
}
|
||||
}
|
||||
|
||||
// Try setup.py
|
||||
var setupPath = Path.Combine(projectRoot, "setup.py");
|
||||
if (File.Exists(setupPath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(setupPath, ct);
|
||||
var nameMatch = Regex.Match(content, @"name\s*=\s*[""']([^""']+)[""']");
|
||||
if (nameMatch.Success)
|
||||
{
|
||||
return new PythonProjectInfo { Name = nameMatch.Groups[1].Value };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to directory name
|
||||
return new PythonProjectInfo { Name = Path.GetFileName(projectRoot) ?? "unknown" };
|
||||
}
|
||||
|
||||
private static List<string> GetPythonFiles(string projectRoot)
|
||||
{
|
||||
var excludeDirs = new[] { "__pycache__", ".venv", "venv", "env", ".tox", ".eggs", "build", "dist", "node_modules" };
|
||||
|
||||
return Directory.EnumerateFiles(projectRoot, "*.py", SearchOption.AllDirectories)
|
||||
.Where(f => !excludeDirs.Any(d => f.Contains(Path.DirectorySeparatorChar + d + Path.DirectorySeparatorChar)))
|
||||
.Where(f => !f.EndsWith("_test.py") && !f.EndsWith("test_.py") && !f.Contains("/tests/") && !f.Contains("\\tests\\"))
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string GetModuleName(string relativePath)
|
||||
{
|
||||
// Convert file path to Python module name
|
||||
var modulePath = relativePath
|
||||
.Replace(Path.DirectorySeparatorChar, '.')
|
||||
.Replace('/', '.');
|
||||
|
||||
if (modulePath.EndsWith(".py"))
|
||||
{
|
||||
modulePath = modulePath[..^3];
|
||||
}
|
||||
|
||||
if (modulePath.EndsWith(".__init__"))
|
||||
{
|
||||
modulePath = modulePath[..^9];
|
||||
}
|
||||
|
||||
return modulePath;
|
||||
}
|
||||
|
||||
private static List<PythonFunctionInfo> ExtractFunctions(string content, string file, string moduleName, string packageName)
|
||||
{
|
||||
var functions = new List<PythonFunctionInfo>();
|
||||
|
||||
// Extract function definitions
|
||||
ExtractFunctionDefs(content, file, moduleName, packageName, functions);
|
||||
|
||||
// Extract class methods
|
||||
ExtractClassMethods(content, file, moduleName, packageName, functions);
|
||||
|
||||
// Extract decorated route handlers
|
||||
ExtractDecoratedHandlers(content, file, moduleName, packageName, functions);
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
private static void ExtractFunctionDefs(string content, string file, string moduleName, string packageName, List<PythonFunctionInfo> functions)
|
||||
{
|
||||
var pattern = new Regex(
|
||||
@"^(?<indent>\s*)(?:async\s+)?def\s+(?<name>\w+)\s*\((?<params>[^)]*)\)\s*(?:->.*?)?:",
|
||||
RegexOptions.Multiline);
|
||||
|
||||
foreach (Match match in pattern.Matches(content))
|
||||
{
|
||||
var indent = match.Groups["indent"].Value;
|
||||
var funcName = match.Groups["name"].Value;
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
|
||||
// Skip class methods (they have indentation)
|
||||
if (indent.Length > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isPublic = !funcName.StartsWith('_');
|
||||
var nodeId = $"py:{packageName}/{moduleName}.{funcName}";
|
||||
|
||||
functions.Add(new PythonFunctionInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = funcName,
|
||||
FullName = $"{moduleName}.{funcName}",
|
||||
Module = moduleName,
|
||||
Line = lineNumber,
|
||||
IsPublic = isPublic,
|
||||
IsAsync = match.Value.Contains("async"),
|
||||
Decorators = ExtractDecorators(content, match.Index),
|
||||
Calls = ExtractCallsFromFunction(content, match.Index)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractClassMethods(string content, string file, string moduleName, string packageName, List<PythonFunctionInfo> functions)
|
||||
{
|
||||
var classPattern = new Regex(@"^class\s+(\w+)(?:\([^)]*\))?:", RegexOptions.Multiline);
|
||||
|
||||
foreach (Match classMatch in classPattern.Matches(content))
|
||||
{
|
||||
var className = classMatch.Groups[1].Value;
|
||||
var classStart = classMatch.Index;
|
||||
|
||||
// Find methods in the class
|
||||
var methodPattern = new Regex(
|
||||
@"^\s{4}(?:async\s+)?def\s+(\w+)\s*\((?:self|cls)(?:,\s*[^)]*)??\)\s*(?:->.*?)?:",
|
||||
RegexOptions.Multiline);
|
||||
|
||||
// Get approximate class body (until next class or end of file)
|
||||
var nextClassMatch = classPattern.Match(content, classStart + classMatch.Length);
|
||||
var classEnd = nextClassMatch.Success ? nextClassMatch.Index : content.Length;
|
||||
var classBody = content[classStart..classEnd];
|
||||
|
||||
foreach (Match methodMatch in methodPattern.Matches(classBody))
|
||||
{
|
||||
var methodName = methodMatch.Groups[1].Value;
|
||||
var lineNumber = content[..(classStart + methodMatch.Index)].Count(c => c == '\n') + 1;
|
||||
|
||||
var isPublic = !methodName.StartsWith('_');
|
||||
var nodeId = $"py:{packageName}/{moduleName}.{className}.{methodName}";
|
||||
|
||||
functions.Add(new PythonFunctionInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = methodName,
|
||||
FullName = $"{moduleName}.{className}.{methodName}",
|
||||
Module = moduleName,
|
||||
ClassName = className,
|
||||
Line = lineNumber,
|
||||
IsPublic = isPublic,
|
||||
IsAsync = methodMatch.Value.Contains("async"),
|
||||
Decorators = ExtractDecorators(classBody, methodMatch.Index),
|
||||
Calls = []
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractDecoratedHandlers(string content, string file, string moduleName, string packageName, List<PythonFunctionInfo> functions)
|
||||
{
|
||||
// Flask/FastAPI route patterns
|
||||
var routePattern = new Regex(
|
||||
@"@(?:app|router|blueprint)\.(get|post|put|delete|patch|route)\s*\(['""]([^'""]+)['""]",
|
||||
RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match match in routePattern.Matches(content))
|
||||
{
|
||||
var method = match.Groups[1].Value.ToUpperInvariant();
|
||||
var route = match.Groups[2].Value;
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
|
||||
// Find the function after the decorator
|
||||
var funcMatch = Regex.Match(content[(match.Index + match.Length)..], @"(?:async\s+)?def\s+(\w+)");
|
||||
if (funcMatch.Success)
|
||||
{
|
||||
var funcName = funcMatch.Groups[1].Value;
|
||||
var nodeId = $"py:{packageName}/{moduleName}.{funcName}";
|
||||
|
||||
// Check if we already added this function
|
||||
if (!functions.Any(f => f.NodeId == nodeId))
|
||||
{
|
||||
functions.Add(new PythonFunctionInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = funcName,
|
||||
FullName = $"{moduleName}.{funcName}",
|
||||
Module = moduleName,
|
||||
Line = lineNumber,
|
||||
IsPublic = true,
|
||||
IsAsync = funcMatch.Value.Contains("async"),
|
||||
IsRouteHandler = true,
|
||||
HttpMethod = method,
|
||||
HttpRoute = route,
|
||||
Decorators = [match.Value],
|
||||
Calls = []
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ExtractDecorators(string content, int functionIndex)
|
||||
{
|
||||
var decorators = new List<string>();
|
||||
|
||||
// Look backwards from function definition to find decorators
|
||||
var lines = content[..functionIndex].Split('\n');
|
||||
for (var i = lines.Length - 1; i >= 0 && i >= lines.Length - 10; i--)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (line.StartsWith('@'))
|
||||
{
|
||||
decorators.Insert(0, line);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(line) && !line.StartsWith('#'))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return decorators;
|
||||
}
|
||||
|
||||
private static List<PythonCallInfo> ExtractCallsFromFunction(string content, int startIndex)
|
||||
{
|
||||
// Simplified: would need proper AST parsing for accurate results
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PythonProjectInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PythonFunctionInfo
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string FullName { get; init; }
|
||||
public required string Module { get; init; }
|
||||
public string? ClassName { get; init; }
|
||||
public int Line { get; init; }
|
||||
public bool IsPublic { get; init; }
|
||||
public bool IsAsync { get; init; }
|
||||
public bool IsRouteHandler { get; init; }
|
||||
public string? HttpMethod { get; init; }
|
||||
public string? HttpRoute { get; init; }
|
||||
public List<string> Decorators { get; init; } = [];
|
||||
public List<PythonCallInfo> Calls { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PythonCallInfo
|
||||
{
|
||||
public required string TargetNodeId { get; init; }
|
||||
public int Line { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PythonEntrypointClassifier.cs
|
||||
// Sprint: SPRINT_3610_0004_0001_python_callgraph
|
||||
// Description: Classifies Python functions as entrypoints based on framework patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Python;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies Python functions as entrypoints based on framework patterns.
|
||||
/// Supports Django, Flask, FastAPI, Celery, and other frameworks.
|
||||
/// </summary>
|
||||
internal sealed class PythonEntrypointClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Decorator patterns that indicate entrypoints.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, EntrypointType> DecoratorPatterns = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Flask
|
||||
["@app.route"] = EntrypointType.HttpHandler,
|
||||
["@blueprint.route"] = EntrypointType.HttpHandler,
|
||||
["@app.get"] = EntrypointType.HttpHandler,
|
||||
["@app.post"] = EntrypointType.HttpHandler,
|
||||
["@app.put"] = EntrypointType.HttpHandler,
|
||||
["@app.delete"] = EntrypointType.HttpHandler,
|
||||
|
||||
// FastAPI
|
||||
["@router.get"] = EntrypointType.HttpHandler,
|
||||
["@router.post"] = EntrypointType.HttpHandler,
|
||||
["@router.put"] = EntrypointType.HttpHandler,
|
||||
["@router.delete"] = EntrypointType.HttpHandler,
|
||||
["@router.patch"] = EntrypointType.HttpHandler,
|
||||
["@app.websocket"] = EntrypointType.WebSocketHandler,
|
||||
|
||||
// Django REST Framework
|
||||
["@api_view"] = EntrypointType.HttpHandler,
|
||||
["@action"] = EntrypointType.HttpHandler,
|
||||
|
||||
// Celery
|
||||
["@app.task"] = EntrypointType.MessageHandler,
|
||||
["@celery.task"] = EntrypointType.MessageHandler,
|
||||
["@shared_task"] = EntrypointType.MessageHandler,
|
||||
|
||||
// APScheduler / cron
|
||||
["@scheduler.scheduled_job"] = EntrypointType.ScheduledJob,
|
||||
|
||||
// Click CLI
|
||||
["@click.command"] = EntrypointType.CliCommand,
|
||||
["@click.group"] = EntrypointType.CliCommand,
|
||||
|
||||
// Typer CLI
|
||||
["@app.command"] = EntrypointType.CliCommand,
|
||||
|
||||
// gRPC
|
||||
["@grpc"] = EntrypointType.GrpcMethod,
|
||||
|
||||
// AWS Lambda
|
||||
["@lambda_handler"] = EntrypointType.Lambda,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Django view class method patterns.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> DjangoViewMethods = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"get", "post", "put", "patch", "delete", "head", "options", "trace"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a function based on its info.
|
||||
/// </summary>
|
||||
public EntrypointType? Classify(PythonFunctionInfo func)
|
||||
{
|
||||
// Check decorators first
|
||||
foreach (var decorator in func.Decorators)
|
||||
{
|
||||
foreach (var (pattern, entrypointType) in DecoratorPatterns)
|
||||
{
|
||||
if (decorator.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return entrypointType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route handlers are HTTP entrypoints
|
||||
if (func.IsRouteHandler)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Django view classes
|
||||
if (func.ClassName is not null)
|
||||
{
|
||||
// Check if class inherits from common view bases
|
||||
if (func.ClassName.EndsWith("View", StringComparison.OrdinalIgnoreCase) ||
|
||||
func.ClassName.EndsWith("ViewSet", StringComparison.OrdinalIgnoreCase) ||
|
||||
func.ClassName.EndsWith("APIView", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (DjangoViewMethods.Contains(func.Name))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// gRPC Servicer classes
|
||||
if (func.ClassName.EndsWith("Servicer", StringComparison.OrdinalIgnoreCase) && func.IsPublic)
|
||||
{
|
||||
return EntrypointType.GrpcMethod;
|
||||
}
|
||||
}
|
||||
|
||||
// AWS Lambda handler convention
|
||||
if (func.Name == "handler" || func.Name == "lambda_handler")
|
||||
{
|
||||
return EntrypointType.Lambda;
|
||||
}
|
||||
|
||||
// Main function
|
||||
if (func.Name == "main" && func.IsPublic)
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
|
||||
// CLI command functions
|
||||
if (func.Module.Contains("cli", StringComparison.OrdinalIgnoreCase) ||
|
||||
func.Module.Contains("command", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (func.Name is "run" or "execute" or "main")
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PythonSinkMatcher.cs
|
||||
// Sprint: SPRINT_3610_0004_0001_python_callgraph
|
||||
// Description: Matches Python function calls to known security sinks.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Python;
|
||||
|
||||
/// <summary>
|
||||
/// Matches Python function calls to known security-relevant sinks.
|
||||
/// </summary>
|
||||
public sealed class PythonSinkMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of known Python sinks.
|
||||
/// </summary>
|
||||
private static readonly List<PythonSinkPattern> SinkPatterns =
|
||||
[
|
||||
// Command injection
|
||||
new("os", "system", SinkCategory.CmdExec),
|
||||
new("os", "popen", SinkCategory.CmdExec),
|
||||
new("os", "spawn", SinkCategory.CmdExec),
|
||||
new("os", "spawnl", SinkCategory.CmdExec),
|
||||
new("os", "spawnle", SinkCategory.CmdExec),
|
||||
new("os", "spawnlp", SinkCategory.CmdExec),
|
||||
new("os", "spawnlpe", SinkCategory.CmdExec),
|
||||
new("os", "spawnv", SinkCategory.CmdExec),
|
||||
new("os", "spawnve", SinkCategory.CmdExec),
|
||||
new("os", "spawnvp", SinkCategory.CmdExec),
|
||||
new("os", "spawnvpe", SinkCategory.CmdExec),
|
||||
new("subprocess", "run", SinkCategory.CmdExec),
|
||||
new("subprocess", "call", SinkCategory.CmdExec),
|
||||
new("subprocess", "check_call", SinkCategory.CmdExec),
|
||||
new("subprocess", "check_output", SinkCategory.CmdExec),
|
||||
new("subprocess", "Popen", SinkCategory.CmdExec),
|
||||
new("commands", "getoutput", SinkCategory.CmdExec),
|
||||
new("commands", "getstatusoutput", SinkCategory.CmdExec),
|
||||
|
||||
// SQL injection
|
||||
new("sqlite3", "execute", SinkCategory.SqlRaw),
|
||||
new("sqlite3", "executemany", SinkCategory.SqlRaw),
|
||||
new("sqlite3", "executescript", SinkCategory.SqlRaw),
|
||||
new("psycopg2", "execute", SinkCategory.SqlRaw),
|
||||
new("pymysql", "execute", SinkCategory.SqlRaw),
|
||||
new("mysql.connector", "execute", SinkCategory.SqlRaw),
|
||||
new("sqlalchemy", "execute", SinkCategory.SqlRaw),
|
||||
new("sqlalchemy", "text", SinkCategory.SqlRaw),
|
||||
new("django.db", "raw", SinkCategory.SqlRaw),
|
||||
new("django.db.connection", "execute", SinkCategory.SqlRaw),
|
||||
|
||||
// Path traversal
|
||||
new("os", "open", SinkCategory.PathTraversal),
|
||||
new("os", "remove", SinkCategory.PathTraversal),
|
||||
new("os", "unlink", SinkCategory.PathTraversal),
|
||||
new("os", "rename", SinkCategory.PathTraversal),
|
||||
new("os", "mkdir", SinkCategory.PathTraversal),
|
||||
new("os", "makedirs", SinkCategory.PathTraversal),
|
||||
new("os", "rmdir", SinkCategory.PathTraversal),
|
||||
new("os", "removedirs", SinkCategory.PathTraversal),
|
||||
new("os.path", "join", SinkCategory.PathTraversal),
|
||||
new("shutil", "copy", SinkCategory.PathTraversal),
|
||||
new("shutil", "copy2", SinkCategory.PathTraversal),
|
||||
new("shutil", "copytree", SinkCategory.PathTraversal),
|
||||
new("shutil", "move", SinkCategory.PathTraversal),
|
||||
new("shutil", "rmtree", SinkCategory.PathTraversal),
|
||||
new("pathlib", "open", SinkCategory.PathTraversal),
|
||||
new("pathlib", "read_text", SinkCategory.PathTraversal),
|
||||
new("pathlib", "read_bytes", SinkCategory.PathTraversal),
|
||||
new("pathlib", "write_text", SinkCategory.PathTraversal),
|
||||
new("pathlib", "write_bytes", SinkCategory.PathTraversal),
|
||||
new("builtins", "open", SinkCategory.PathTraversal),
|
||||
|
||||
// SSRF
|
||||
new("urllib", "urlopen", SinkCategory.Ssrf),
|
||||
new("urllib.request", "urlopen", SinkCategory.Ssrf),
|
||||
new("urllib.request", "Request", SinkCategory.Ssrf),
|
||||
new("urllib2", "urlopen", SinkCategory.Ssrf),
|
||||
new("httplib", "HTTPConnection", SinkCategory.Ssrf),
|
||||
new("http.client", "HTTPConnection", SinkCategory.Ssrf),
|
||||
new("http.client", "HTTPSConnection", SinkCategory.Ssrf),
|
||||
new("requests", "get", SinkCategory.Ssrf),
|
||||
new("requests", "post", SinkCategory.Ssrf),
|
||||
new("requests", "put", SinkCategory.Ssrf),
|
||||
new("requests", "delete", SinkCategory.Ssrf),
|
||||
new("requests", "patch", SinkCategory.Ssrf),
|
||||
new("requests", "head", SinkCategory.Ssrf),
|
||||
new("requests", "request", SinkCategory.Ssrf),
|
||||
new("httpx", "get", SinkCategory.Ssrf),
|
||||
new("httpx", "post", SinkCategory.Ssrf),
|
||||
new("aiohttp", "request", SinkCategory.Ssrf),
|
||||
|
||||
// Deserialization
|
||||
new("pickle", "load", SinkCategory.UnsafeDeser),
|
||||
new("pickle", "loads", SinkCategory.UnsafeDeser),
|
||||
new("cPickle", "load", SinkCategory.UnsafeDeser),
|
||||
new("cPickle", "loads", SinkCategory.UnsafeDeser),
|
||||
new("yaml", "load", SinkCategory.UnsafeDeser),
|
||||
new("yaml", "unsafe_load", SinkCategory.UnsafeDeser),
|
||||
new("yaml", "full_load", SinkCategory.UnsafeDeser),
|
||||
new("marshal", "load", SinkCategory.UnsafeDeser),
|
||||
new("marshal", "loads", SinkCategory.UnsafeDeser),
|
||||
new("shelve", "open", SinkCategory.UnsafeDeser),
|
||||
new("jsonpickle", "decode", SinkCategory.UnsafeDeser),
|
||||
|
||||
// XXE
|
||||
new("xml.etree.ElementTree", "parse", SinkCategory.XxeInjection),
|
||||
new("xml.etree.ElementTree", "fromstring", SinkCategory.XxeInjection),
|
||||
new("xml.dom.minidom", "parse", SinkCategory.XxeInjection),
|
||||
new("xml.dom.minidom", "parseString", SinkCategory.XxeInjection),
|
||||
new("xml.sax", "parse", SinkCategory.XxeInjection),
|
||||
new("lxml.etree", "parse", SinkCategory.XxeInjection),
|
||||
new("lxml.etree", "fromstring", SinkCategory.XxeInjection),
|
||||
|
||||
// Code injection
|
||||
new("builtins", "eval", SinkCategory.CodeInjection),
|
||||
new("builtins", "exec", SinkCategory.CodeInjection),
|
||||
new("builtins", "compile", SinkCategory.CodeInjection),
|
||||
new("builtins", "__import__", SinkCategory.CodeInjection),
|
||||
new("importlib", "import_module", SinkCategory.CodeInjection),
|
||||
|
||||
// Template injection
|
||||
new("jinja2", "Template", SinkCategory.TemplateInjection),
|
||||
new("jinja2", "from_string", SinkCategory.TemplateInjection),
|
||||
new("mako.template", "Template", SinkCategory.TemplateInjection),
|
||||
new("django.template", "Template", SinkCategory.TemplateInjection),
|
||||
|
||||
// Open redirect
|
||||
new("flask", "redirect", SinkCategory.OpenRedirect),
|
||||
new("django.shortcuts", "redirect", SinkCategory.OpenRedirect),
|
||||
new("django.http", "HttpResponseRedirect", SinkCategory.OpenRedirect),
|
||||
|
||||
// Log injection
|
||||
new("logging", "info", SinkCategory.LogInjection),
|
||||
new("logging", "warning", SinkCategory.LogInjection),
|
||||
new("logging", "error", SinkCategory.LogInjection),
|
||||
new("logging", "debug", SinkCategory.LogInjection),
|
||||
new("logging", "critical", SinkCategory.LogInjection),
|
||||
|
||||
// Crypto weaknesses
|
||||
new("hashlib", "md5", SinkCategory.CryptoWeak),
|
||||
new("hashlib", "sha1", SinkCategory.CryptoWeak),
|
||||
new("Crypto.Cipher", "DES", SinkCategory.CryptoWeak),
|
||||
new("Crypto.Cipher", "Blowfish", SinkCategory.CryptoWeak),
|
||||
new("random", "random", SinkCategory.CryptoWeak),
|
||||
new("random", "randint", SinkCategory.CryptoWeak),
|
||||
new("random", "choice", SinkCategory.CryptoWeak),
|
||||
|
||||
// LDAP injection
|
||||
new("ldap", "search", SinkCategory.LdapInjection),
|
||||
new("ldap", "search_s", SinkCategory.LdapInjection),
|
||||
new("ldap3", "search", SinkCategory.LdapInjection),
|
||||
|
||||
// XPath injection
|
||||
new("lxml.etree", "xpath", SinkCategory.XPathInjection),
|
||||
new("xml.etree.ElementTree", "findall", SinkCategory.XPathInjection),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Matches a function call to a sink category.
|
||||
/// </summary>
|
||||
/// <param name="module">Python module name.</param>
|
||||
/// <param name="funcName">Function name.</param>
|
||||
/// <returns>Sink category if matched; otherwise, null.</returns>
|
||||
public SinkCategory? Match(string module, string funcName)
|
||||
{
|
||||
foreach (var pattern in SinkPatterns)
|
||||
{
|
||||
if (pattern.Matches(module, funcName))
|
||||
{
|
||||
return pattern.Category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for matching Python sinks.
|
||||
/// </summary>
|
||||
internal sealed record PythonSinkPattern(string Module, string Function, SinkCategory Category)
|
||||
{
|
||||
public bool Matches(string module, string funcName)
|
||||
{
|
||||
// Check module match
|
||||
var moduleMatch = string.Equals(module, Module, StringComparison.OrdinalIgnoreCase) ||
|
||||
module.EndsWith("." + Module, StringComparison.OrdinalIgnoreCase) ||
|
||||
module.StartsWith(Module + ".", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!moduleMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check function match
|
||||
return string.Equals(funcName, Function, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RubyCallGraphExtractor.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Ruby call graph extractor using AST-based analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Ruby;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts call graphs from Ruby source code.
|
||||
/// Supports Rails, Sinatra, and Grape frameworks.
|
||||
/// </summary>
|
||||
public sealed class RubyCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly ILogger<RubyCallGraphExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RubyEntrypointClassifier _entrypointClassifier;
|
||||
private readonly RubySinkMatcher _sinkMatcher;
|
||||
|
||||
public RubyCallGraphExtractor(
|
||||
ILogger<RubyCallGraphExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_entrypointClassifier = new RubyEntrypointClassifier();
|
||||
_sinkMatcher = new RubySinkMatcher();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Language => "ruby";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(
|
||||
CallGraphExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var targetPath = Path.GetFullPath(request.TargetPath);
|
||||
var projectRoot = FindRubyProjectRoot(targetPath);
|
||||
|
||||
if (projectRoot is null)
|
||||
{
|
||||
throw new FileNotFoundException($"No Ruby project found at or above: {targetPath}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Starting Ruby call graph extraction for project at {Path}", projectRoot);
|
||||
|
||||
var projectInfo = await ReadProjectInfoAsync(projectRoot, cancellationToken);
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
|
||||
var rubyFiles = GetRubyFiles(projectRoot);
|
||||
_logger.LogDebug("Found {Count} Ruby files", rubyFiles.Count);
|
||||
|
||||
foreach (var rubyFile in rubyFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(rubyFile, cancellationToken);
|
||||
var relativePath = Path.GetRelativePath(projectRoot, rubyFile);
|
||||
|
||||
var functions = ExtractMethods(content, relativePath, projectInfo.Name);
|
||||
|
||||
foreach (var func in functions)
|
||||
{
|
||||
var entrypointType = _entrypointClassifier.Classify(func);
|
||||
var sinkCategory = _sinkMatcher.Match(func.Module, func.Name);
|
||||
|
||||
var node = new CallGraphNode(
|
||||
NodeId: func.NodeId,
|
||||
Symbol: func.FullName,
|
||||
File: relativePath,
|
||||
Line: func.Line,
|
||||
Package: projectInfo.Name,
|
||||
Visibility: func.IsPublic ? Visibility.Public : Visibility.Private,
|
||||
IsEntrypoint: entrypointType.HasValue,
|
||||
EntrypointType: entrypointType,
|
||||
IsSink: sinkCategory.HasValue,
|
||||
SinkCategory: sinkCategory);
|
||||
|
||||
nodesById.TryAdd(node.NodeId, node);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse {File}", rubyFile);
|
||||
}
|
||||
}
|
||||
|
||||
return BuildSnapshot(request.ScanId, nodesById, edges);
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildSnapshot(
|
||||
string scanId,
|
||||
Dictionary<string, CallGraphNode> nodesById,
|
||||
HashSet<CallGraphEdge> edges)
|
||||
{
|
||||
var nodes = nodesById.Values
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypointIds = nodes
|
||||
.Where(n => n.IsEntrypoint)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinkIds = nodes
|
||||
.Where(n => n.IsSink)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: nodes,
|
||||
Edges: orderedEdges,
|
||||
EntrypointIds: entrypointIds,
|
||||
SinkIds: sinkIds);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ruby call graph extracted: {Nodes} nodes, {Edges} edges, {Entrypoints} entrypoints, {Sinks} sinks",
|
||||
nodes.Length, orderedEdges.Length, entrypointIds.Length, sinkIds.Length);
|
||||
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private static string? FindRubyProjectRoot(string startPath)
|
||||
{
|
||||
var current = startPath;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
if (File.Exists(Path.Combine(current, "Gemfile")) ||
|
||||
File.Exists(Path.Combine(current, "config.ru")) ||
|
||||
Directory.Exists(Path.Combine(current, "app")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
current = Path.GetDirectoryName(current);
|
||||
}
|
||||
return startPath;
|
||||
}
|
||||
|
||||
private static async Task<RubyProjectInfo> ReadProjectInfoAsync(string projectRoot, CancellationToken ct)
|
||||
{
|
||||
var gemfilePath = Path.Combine(projectRoot, "Gemfile");
|
||||
var name = Path.GetFileName(projectRoot) ?? "unknown";
|
||||
|
||||
if (File.Exists(gemfilePath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(gemfilePath, ct);
|
||||
// Check for Rails
|
||||
if (content.Contains("'rails'") || content.Contains("\"rails\""))
|
||||
{
|
||||
return new RubyProjectInfo { Name = name, Framework = "rails" };
|
||||
}
|
||||
if (content.Contains("'sinatra'") || content.Contains("\"sinatra\""))
|
||||
{
|
||||
return new RubyProjectInfo { Name = name, Framework = "sinatra" };
|
||||
}
|
||||
if (content.Contains("'grape'") || content.Contains("\"grape\""))
|
||||
{
|
||||
return new RubyProjectInfo { Name = name, Framework = "grape" };
|
||||
}
|
||||
}
|
||||
|
||||
return new RubyProjectInfo { Name = name };
|
||||
}
|
||||
|
||||
private static List<string> GetRubyFiles(string projectRoot)
|
||||
{
|
||||
var excludeDirs = new[] { "vendor", "node_modules", ".bundle", "tmp", "log", "coverage" };
|
||||
|
||||
return Directory.EnumerateFiles(projectRoot, "*.rb", SearchOption.AllDirectories)
|
||||
.Where(f => !excludeDirs.Any(d => f.Contains(Path.DirectorySeparatorChar + d + Path.DirectorySeparatorChar)))
|
||||
.Where(f => !f.EndsWith("_spec.rb") && !f.EndsWith("_test.rb"))
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<RubyMethodInfo> ExtractMethods(string content, string file, string projectName)
|
||||
{
|
||||
var methods = new List<RubyMethodInfo>();
|
||||
|
||||
// Extract class/module definitions with methods
|
||||
ExtractClassMethods(content, file, projectName, methods);
|
||||
|
||||
// Extract Rails route handlers
|
||||
ExtractRailsRoutes(content, file, projectName, methods);
|
||||
|
||||
// Extract Sinatra routes
|
||||
ExtractSinatraRoutes(content, file, projectName, methods);
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
private static void ExtractClassMethods(string content, string file, string projectName, List<RubyMethodInfo> methods)
|
||||
{
|
||||
var classPattern = new Regex(@"^\s*class\s+(\w+)(?:\s*<\s*(\w+(?:::\w+)*))?", RegexOptions.Multiline);
|
||||
var methodPattern = new Regex(@"^\s*def\s+(\w+[?!=]?)(?:\s*\(([^)]*)\))?", RegexOptions.Multiline);
|
||||
|
||||
var currentClass = "";
|
||||
var parentClass = "";
|
||||
var lines = content.Split('\n');
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
|
||||
var classMatch = classPattern.Match(line);
|
||||
if (classMatch.Success)
|
||||
{
|
||||
currentClass = classMatch.Groups[1].Value;
|
||||
parentClass = classMatch.Groups[2].Value;
|
||||
continue;
|
||||
}
|
||||
|
||||
var methodMatch = methodPattern.Match(line);
|
||||
if (methodMatch.Success)
|
||||
{
|
||||
var methodName = methodMatch.Groups[1].Value;
|
||||
var isPublic = !methodName.StartsWith('_');
|
||||
|
||||
var nodeId = string.IsNullOrEmpty(currentClass)
|
||||
? $"rb:{projectName}/{Path.GetFileNameWithoutExtension(file)}.{methodName}"
|
||||
: $"rb:{projectName}/{currentClass}.{methodName}";
|
||||
|
||||
methods.Add(new RubyMethodInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = methodName,
|
||||
FullName = string.IsNullOrEmpty(currentClass) ? methodName : $"{currentClass}#{methodName}",
|
||||
Module = Path.GetFileNameWithoutExtension(file),
|
||||
ClassName = currentClass,
|
||||
ParentClass = parentClass,
|
||||
Line = i + 1,
|
||||
IsPublic = isPublic
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractRailsRoutes(string content, string file, string projectName, List<RubyMethodInfo> methods)
|
||||
{
|
||||
// Rails routes in routes.rb
|
||||
var routePattern = new Regex(
|
||||
@"^\s*(get|post|put|patch|delete|resources?|match)\s+['""]?([^'""]+)['""]?(?:.*to:\s*['""]?(\w+)#(\w+)['""]?)?",
|
||||
RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match match in routePattern.Matches(content))
|
||||
{
|
||||
var method = match.Groups[1].Value.ToUpperInvariant();
|
||||
var route = match.Groups[2].Value;
|
||||
var controller = match.Groups[3].Value;
|
||||
var action = match.Groups[4].Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(action))
|
||||
{
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
var nodeId = $"rb:{projectName}/{controller}Controller.{action}";
|
||||
|
||||
methods.Add(new RubyMethodInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = action,
|
||||
FullName = $"{controller}Controller#{action}",
|
||||
Module = "routes",
|
||||
ClassName = $"{controller}Controller",
|
||||
Line = lineNumber,
|
||||
IsPublic = true,
|
||||
IsRouteHandler = true,
|
||||
HttpMethod = method,
|
||||
HttpRoute = route
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractSinatraRoutes(string content, string file, string projectName, List<RubyMethodInfo> methods)
|
||||
{
|
||||
var routePattern = new Regex(
|
||||
@"^\s*(get|post|put|patch|delete)\s+['""]([^'""]+)['""]",
|
||||
RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (Match match in routePattern.Matches(content))
|
||||
{
|
||||
var method = match.Groups[1].Value.ToUpperInvariant();
|
||||
var route = match.Groups[2].Value;
|
||||
var lineNumber = content[..match.Index].Count(c => c == '\n') + 1;
|
||||
var handlerName = $"{method}_{SanitizeRoute(route)}";
|
||||
var nodeId = $"rb:{projectName}/{Path.GetFileNameWithoutExtension(file)}.{handlerName}";
|
||||
|
||||
methods.Add(new RubyMethodInfo
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Name = handlerName,
|
||||
FullName = handlerName,
|
||||
Module = Path.GetFileNameWithoutExtension(file),
|
||||
Line = lineNumber,
|
||||
IsPublic = true,
|
||||
IsRouteHandler = true,
|
||||
HttpMethod = method,
|
||||
HttpRoute = route
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeRoute(string route)
|
||||
{
|
||||
return Regex.Replace(route, @"[/:{}*?]", "_").Trim('_');
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RubyProjectInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Framework { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RubyMethodInfo
|
||||
{
|
||||
public required string NodeId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string FullName { get; init; }
|
||||
public required string Module { get; init; }
|
||||
public string? ClassName { get; init; }
|
||||
public string? ParentClass { get; init; }
|
||||
public int Line { get; init; }
|
||||
public bool IsPublic { get; init; }
|
||||
public bool IsRouteHandler { get; init; }
|
||||
public string? HttpMethod { get; init; }
|
||||
public string? HttpRoute { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RubyEntrypointClassifier.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Classifies Ruby methods as entrypoints based on framework patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Ruby;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies Ruby methods as entrypoints based on framework patterns.
|
||||
/// Supports Rails, Sinatra, and Grape frameworks.
|
||||
/// </summary>
|
||||
internal sealed class RubyEntrypointClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Rails controller action method names.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> RailsActionNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"index", "show", "new", "create", "edit", "update", "destroy"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Rails callback method names.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> RailsCallbacks = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"before_action", "after_action", "around_action",
|
||||
"before_filter", "after_filter", "around_filter"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parent classes that indicate controller.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ControllerParentClasses = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ApplicationController",
|
||||
"ActionController::Base",
|
||||
"ActionController::API",
|
||||
"Sinatra::Base",
|
||||
"Grape::API"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a method based on its info.
|
||||
/// </summary>
|
||||
public EntrypointType? Classify(RubyMethodInfo method)
|
||||
{
|
||||
// Route handlers are HTTP entrypoints
|
||||
if (method.IsRouteHandler)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Check for Rails controller
|
||||
if (method.ClassName?.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
// Standard CRUD actions
|
||||
if (RailsActionNames.Contains(method.Name))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
|
||||
// Public methods in controllers are typically actions
|
||||
if (method.IsPublic && !method.Name.StartsWith("_"))
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Check parent class for controller inheritance
|
||||
if (!string.IsNullOrEmpty(method.ParentClass) &&
|
||||
ControllerParentClasses.Contains(method.ParentClass))
|
||||
{
|
||||
if (method.IsPublic)
|
||||
{
|
||||
return EntrypointType.HttpHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Sidekiq/ActiveJob workers
|
||||
if (method.ClassName?.EndsWith("Job", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
method.ClassName?.EndsWith("Worker", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
if (method.Name is "perform" or "call")
|
||||
{
|
||||
return EntrypointType.MessageHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Rake tasks
|
||||
if (method.Module.Contains("tasks", StringComparison.OrdinalIgnoreCase) ||
|
||||
method.Module.Contains("rake", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EntrypointType.ScheduledJob;
|
||||
}
|
||||
|
||||
// CLI commands
|
||||
if (method.ClassName?.EndsWith("CLI", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
method.ClassName?.EndsWith("Command", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
if (method.Name is "run" or "call" or "execute")
|
||||
{
|
||||
return EntrypointType.CliCommand;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RubySinkMatcher.cs
|
||||
// Sprint: SPRINT_3610_0005_0001_ruby_php_bun_deno
|
||||
// Description: Matches Ruby method calls to known security sinks.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Ruby;
|
||||
|
||||
/// <summary>
|
||||
/// Matches Ruby method calls to known security-relevant sinks.
|
||||
/// </summary>
|
||||
public sealed class RubySinkMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry of known Ruby sinks.
|
||||
/// </summary>
|
||||
private static readonly List<RubySinkPattern> SinkPatterns =
|
||||
[
|
||||
// Command injection
|
||||
new("Kernel", "system", SinkCategory.CmdExec),
|
||||
new("Kernel", "exec", SinkCategory.CmdExec),
|
||||
new("Kernel", "`", SinkCategory.CmdExec),
|
||||
new("Kernel", "spawn", SinkCategory.CmdExec),
|
||||
new("IO", "popen", SinkCategory.CmdExec),
|
||||
new("Open3", "popen3", SinkCategory.CmdExec),
|
||||
new("Open3", "capture3", SinkCategory.CmdExec),
|
||||
new("PTY", "spawn", SinkCategory.CmdExec),
|
||||
|
||||
// SQL injection
|
||||
new("ActiveRecord", "find_by_sql", SinkCategory.SqlRaw),
|
||||
new("ActiveRecord", "execute", SinkCategory.SqlRaw),
|
||||
new("ActiveRecord", "where", SinkCategory.SqlRaw),
|
||||
new("ActiveRecord", "order", SinkCategory.SqlRaw),
|
||||
new("ActiveRecord", "pluck", SinkCategory.SqlRaw),
|
||||
new("ActiveRecord", "select", SinkCategory.SqlRaw),
|
||||
new("Sequel", "run", SinkCategory.SqlRaw),
|
||||
new("Sequel", "execute", SinkCategory.SqlRaw),
|
||||
new("PG::Connection", "exec", SinkCategory.SqlRaw),
|
||||
new("Mysql2::Client", "query", SinkCategory.SqlRaw),
|
||||
|
||||
// Path traversal
|
||||
new("File", "open", SinkCategory.PathTraversal),
|
||||
new("File", "read", SinkCategory.PathTraversal),
|
||||
new("File", "write", SinkCategory.PathTraversal),
|
||||
new("File", "delete", SinkCategory.PathTraversal),
|
||||
new("File", "unlink", SinkCategory.PathTraversal),
|
||||
new("FileUtils", "cp", SinkCategory.PathTraversal),
|
||||
new("FileUtils", "mv", SinkCategory.PathTraversal),
|
||||
new("FileUtils", "rm", SinkCategory.PathTraversal),
|
||||
new("FileUtils", "rm_rf", SinkCategory.PathTraversal),
|
||||
new("Dir", "chdir", SinkCategory.PathTraversal),
|
||||
new("Dir", "glob", SinkCategory.PathTraversal),
|
||||
|
||||
// SSRF
|
||||
new("Net::HTTP", "get", SinkCategory.Ssrf),
|
||||
new("Net::HTTP", "post", SinkCategory.Ssrf),
|
||||
new("Net::HTTP", "start", SinkCategory.Ssrf),
|
||||
new("Net::HTTP", "new", SinkCategory.Ssrf),
|
||||
new("HTTParty", "get", SinkCategory.Ssrf),
|
||||
new("HTTParty", "post", SinkCategory.Ssrf),
|
||||
new("Faraday", "get", SinkCategory.Ssrf),
|
||||
new("Faraday", "post", SinkCategory.Ssrf),
|
||||
new("RestClient", "get", SinkCategory.Ssrf),
|
||||
new("RestClient", "post", SinkCategory.Ssrf),
|
||||
new("Typhoeus", "get", SinkCategory.Ssrf),
|
||||
new("Excon", "get", SinkCategory.Ssrf),
|
||||
|
||||
// Deserialization
|
||||
new("Marshal", "load", SinkCategory.UnsafeDeser),
|
||||
new("Marshal", "restore", SinkCategory.UnsafeDeser),
|
||||
new("YAML", "load", SinkCategory.UnsafeDeser),
|
||||
new("YAML", "unsafe_load", SinkCategory.UnsafeDeser),
|
||||
new("Psych", "load", SinkCategory.UnsafeDeser),
|
||||
|
||||
// Code injection
|
||||
new("Kernel", "eval", SinkCategory.CodeInjection),
|
||||
new("Kernel", "instance_eval", SinkCategory.CodeInjection),
|
||||
new("Kernel", "class_eval", SinkCategory.CodeInjection),
|
||||
new("Kernel", "module_eval", SinkCategory.CodeInjection),
|
||||
new("Kernel", "send", SinkCategory.CodeInjection),
|
||||
new("Kernel", "public_send", SinkCategory.CodeInjection),
|
||||
new("Kernel", "constantize", SinkCategory.CodeInjection),
|
||||
|
||||
// Template injection
|
||||
new("ERB", "new", SinkCategory.TemplateInjection),
|
||||
new("ERB", "result", SinkCategory.TemplateInjection),
|
||||
new("Haml::Engine", "new", SinkCategory.TemplateInjection),
|
||||
new("Slim::Template", "new", SinkCategory.TemplateInjection),
|
||||
|
||||
// Open redirect
|
||||
new("redirect_to", "*", SinkCategory.OpenRedirect),
|
||||
new("redirect", "*", SinkCategory.OpenRedirect),
|
||||
|
||||
// Log injection
|
||||
new("Rails.logger", "info", SinkCategory.LogInjection),
|
||||
new("Rails.logger", "warn", SinkCategory.LogInjection),
|
||||
new("Rails.logger", "error", SinkCategory.LogInjection),
|
||||
new("Logger", "info", SinkCategory.LogInjection),
|
||||
|
||||
// XXE
|
||||
new("Nokogiri::XML", "parse", SinkCategory.XxeInjection),
|
||||
new("REXML::Document", "new", SinkCategory.XxeInjection),
|
||||
new("LibXML::XML::Document", "file", SinkCategory.XxeInjection),
|
||||
|
||||
// Crypto weaknesses
|
||||
new("Digest::MD5", "hexdigest", SinkCategory.CryptoWeak),
|
||||
new("Digest::SHA1", "hexdigest", SinkCategory.CryptoWeak),
|
||||
new("OpenSSL::Cipher", "new", SinkCategory.CryptoWeak),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Matches a method call to a sink category.
|
||||
/// </summary>
|
||||
public SinkCategory? Match(string module, string methodName)
|
||||
{
|
||||
foreach (var pattern in SinkPatterns)
|
||||
{
|
||||
if (pattern.Matches(module, methodName))
|
||||
{
|
||||
return pattern.Category;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for matching Ruby sinks.
|
||||
/// </summary>
|
||||
internal sealed record RubySinkPattern(string Class, string Method, SinkCategory Category)
|
||||
{
|
||||
public bool Matches(string module, string methodName)
|
||||
{
|
||||
// Check class match
|
||||
var classMatch = string.Equals(module, Class, StringComparison.OrdinalIgnoreCase) ||
|
||||
module.EndsWith(Class, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!classMatch && !string.Equals(methodName, Class, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check method match (wildcard support)
|
||||
if (Method == "*")
|
||||
{
|
||||
return classMatch || string.Equals(methodName, Class, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(methodName, Method, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CallGraphEdgeComparer.cs
|
||||
// Description: Shared equality comparer for call graph edges.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Equality comparer for <see cref="CallGraphEdge"/> based on source, target, call kind, and call site.
|
||||
/// </summary>
|
||||
public sealed class CallGraphEdgeComparer : IEqualityComparer<CallGraphEdge>
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static readonly CallGraphEdgeComparer Instance = new();
|
||||
|
||||
private CallGraphEdgeComparer() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(CallGraphEdge? x, CallGraphEdge? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.SourceId, y.SourceId, StringComparison.Ordinal)
|
||||
&& string.Equals(x.TargetId, y.TargetId, StringComparison.Ordinal)
|
||||
&& x.CallKind == y.CallKind
|
||||
&& string.Equals(x.CallSite ?? string.Empty, y.CallSite ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetHashCode(CallGraphEdge obj)
|
||||
{
|
||||
return HashCode.Combine(
|
||||
obj.SourceId,
|
||||
obj.TargetId,
|
||||
obj.CallKind,
|
||||
obj.CallSite ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,8 @@ public enum EntrypointType
|
||||
MessageHandler,
|
||||
EventSubscriber,
|
||||
WebSocketHandler,
|
||||
EventHandler,
|
||||
Lambda,
|
||||
Unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AssemblyInfo.cs
|
||||
// Assembly configuration for StellaOps.Scanner.CallGraph
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.CallGraph.Tests")]
|
||||
@@ -7,6 +7,10 @@
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Scanner.CallGraph.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Build.Locator" Version="1.10.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CycloneDX.Core" Version="10.0.1" />
|
||||
<PackageReference Include="CycloneDX.Core" Version="10.0.2" />
|
||||
<PackageReference Include="RoaringBitmap" Version="0.0.9" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -44,7 +44,35 @@ public enum SinkCategory
|
||||
|
||||
/// <summary>Authorization bypass (e.g., JWT none alg, missing authz check)</summary>
|
||||
[JsonStringEnumMemberName("AUTHZ_BYPASS")]
|
||||
AuthzBypass
|
||||
AuthzBypass,
|
||||
|
||||
/// <summary>LDAP injection (e.g., DirContext.search with user input)</summary>
|
||||
[JsonStringEnumMemberName("LDAP_INJECTION")]
|
||||
LdapInjection,
|
||||
|
||||
/// <summary>XPath injection (e.g., XPath.evaluate with user input)</summary>
|
||||
[JsonStringEnumMemberName("XPATH_INJECTION")]
|
||||
XPathInjection,
|
||||
|
||||
/// <summary>XML External Entity injection (XXE)</summary>
|
||||
[JsonStringEnumMemberName("XXE")]
|
||||
XxeInjection,
|
||||
|
||||
/// <summary>Code/expression injection (e.g., eval, ScriptEngine)</summary>
|
||||
[JsonStringEnumMemberName("CODE_INJECTION")]
|
||||
CodeInjection,
|
||||
|
||||
/// <summary>Log injection (e.g., unvalidated user input in logs)</summary>
|
||||
[JsonStringEnumMemberName("LOG_INJECTION")]
|
||||
LogInjection,
|
||||
|
||||
/// <summary>Reflection-based attacks (e.g., Class.forName with user input)</summary>
|
||||
[JsonStringEnumMemberName("REFLECTION")]
|
||||
Reflection,
|
||||
|
||||
/// <summary>Open redirect (e.g., sendRedirect with user-controlled URL)</summary>
|
||||
[JsonStringEnumMemberName("OPEN_REDIRECT")]
|
||||
OpenRedirect
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeterminismVerificationTests.cs
|
||||
// Sprint: SPRINT_3610 (cross-cutting)
|
||||
// Description: Tests to verify call graph extraction produces deterministic output.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests to verify that call graph extraction is deterministic.
|
||||
/// The same input should always produce the same output.
|
||||
/// </summary>
|
||||
[Trait("Category", "Determinism")]
|
||||
public class DeterminismVerificationTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public DeterminismVerificationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphNode_HashIsStable()
|
||||
{
|
||||
// Arrange
|
||||
var node1 = new CallGraphNode(
|
||||
NodeId: "java:com.example.Service.process",
|
||||
Symbol: "process",
|
||||
File: "Service.java",
|
||||
Line: 42,
|
||||
Package: "com.example",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null);
|
||||
|
||||
var node2 = new CallGraphNode(
|
||||
NodeId: "java:com.example.Service.process",
|
||||
Symbol: "process",
|
||||
File: "Service.java",
|
||||
Line: 42,
|
||||
Package: "com.example",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null);
|
||||
|
||||
// Act
|
||||
var hash1 = ComputeNodeHash(node1);
|
||||
var hash2 = ComputeNodeHash(node2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphEdge_OrderingIsStable()
|
||||
{
|
||||
// Arrange
|
||||
var edges = new List<CallGraphEdge>
|
||||
{
|
||||
new("a", "c", CallKind.Direct, "file.java:10"),
|
||||
new("a", "b", CallKind.Direct, "file.java:5"),
|
||||
new("b", "c", CallKind.Virtual, "file.java:15"),
|
||||
new("a", "d", CallKind.Direct, "file.java:20"),
|
||||
};
|
||||
|
||||
// Act - Sort multiple times
|
||||
var sorted1 = SortEdges(edges);
|
||||
var sorted2 = SortEdges(edges);
|
||||
var sorted3 = SortEdges(edges.AsEnumerable().Reverse().ToList());
|
||||
|
||||
// Assert - All should produce same order
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(sorted1),
|
||||
JsonSerializer.Serialize(sorted2));
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(sorted1),
|
||||
JsonSerializer.Serialize(sorted3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallGraphSnapshot_SerializesToSameJson()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateTestSnapshot();
|
||||
|
||||
// Act - Serialize multiple times
|
||||
var json1 = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true });
|
||||
var json2 = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(json1, json2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeOrdering_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var nodes = new List<CallGraphNode>
|
||||
{
|
||||
CreateNode("c.Method", "file3.java", 30),
|
||||
CreateNode("a.Method", "file1.java", 10),
|
||||
CreateNode("b.Method", "file2.java", 20),
|
||||
};
|
||||
|
||||
// Act - Sort using stable ordering
|
||||
var sorted1 = nodes.OrderBy(n => n.NodeId, StringComparer.Ordinal).ToList();
|
||||
var shuffled = new List<CallGraphNode> { nodes[1], nodes[2], nodes[0] };
|
||||
var sorted2 = shuffled.OrderBy(n => n.NodeId, StringComparer.Ordinal).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(sorted1[0].NodeId, sorted2[0].NodeId);
|
||||
Assert.Equal(sorted1[1].NodeId, sorted2[1].NodeId);
|
||||
Assert.Equal(sorted1[2].NodeId, sorted2[2].NodeId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("java:com.example.Service.method")]
|
||||
[InlineData("go:github.com/user/pkg.Function")]
|
||||
[InlineData("py:myapp/views.handler")]
|
||||
[InlineData("js:app/routes.getUser")]
|
||||
[InlineData("native:libfoo.so/process")]
|
||||
public void SymbolId_RoundTripsConsistently(string symbolId)
|
||||
{
|
||||
// Act - Hash the symbol ID multiple times
|
||||
var hash1 = ComputeStringHash(symbolId);
|
||||
var hash2 = ComputeStringHash(symbolId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
private static string ComputeNodeHash(CallGraphNode node)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(node);
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
private static string ComputeStringHash(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
private static List<CallGraphEdge> SortEdges(List<CallGraphEdge> edges)
|
||||
{
|
||||
return edges
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.CallSite, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static CallGraphNode CreateNode(string symbol, string file, int line)
|
||||
{
|
||||
return new CallGraphNode(
|
||||
NodeId: $"java:{symbol}",
|
||||
Symbol: symbol,
|
||||
File: file,
|
||||
Line: line,
|
||||
Package: "test",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: false,
|
||||
SinkCategory: null);
|
||||
}
|
||||
|
||||
private static CallGraphSnapshot CreateTestSnapshot()
|
||||
{
|
||||
var nodes = new List<CallGraphNode>
|
||||
{
|
||||
CreateNode("a.main", "a.java", 1),
|
||||
CreateNode("b.helper", "b.java", 10),
|
||||
}.ToImmutableList();
|
||||
|
||||
var edges = new List<CallGraphEdge>
|
||||
{
|
||||
new("java:a.main", "java:b.helper", CallKind.Direct, "a.java:5"),
|
||||
}.ToImmutableList();
|
||||
|
||||
var entrypoints = new List<string> { "java:a.main" }.ToImmutableList();
|
||||
var sinks = new List<string>().ToImmutableList();
|
||||
|
||||
return new CallGraphSnapshot(
|
||||
ScanId: "test-scan-001",
|
||||
GraphDigest: "sha256:0000",
|
||||
Language: "java",
|
||||
ExtractedAt: new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Nodes: nodes.ToImmutableArray(),
|
||||
Edges: edges.ToImmutableArray(),
|
||||
EntrypointIds: entrypoints.ToImmutableArray(),
|
||||
SinkIds: sinks.ToImmutableArray());
|
||||
}
|
||||
}
|
||||
|
||||
// Extension to convert to immutable list
|
||||
file static class ListExtensions
|
||||
{
|
||||
public static System.Collections.Immutable.ImmutableList<T> ToImmutableList<T>(this List<T> list)
|
||||
=> System.Collections.Immutable.ImmutableList.CreateRange(list);
|
||||
|
||||
public static ImmutableArray<T> ToImmutableArray<T>(this System.Collections.Immutable.ImmutableList<T> list)
|
||||
=> ImmutableArray.CreateRange(list);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaCallGraphExtractorTests.cs
|
||||
// Sprint: SPRINT_3610_0001_0001_java_callgraph (JCG-018)
|
||||
// Description: Unit tests for the Java bytecode call graph extractor.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.CallGraph.Java;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="JavaCallGraphExtractor"/>.
|
||||
/// Tests entrypoint detection, sink matching, and call graph extraction from Java bytecode.
|
||||
/// </summary>
|
||||
public class JavaCallGraphExtractorTests
|
||||
{
|
||||
private readonly JavaCallGraphExtractor _extractor;
|
||||
private readonly DateTimeOffset _fixedTime = DateTimeOffset.Parse("2025-12-19T00:00:00Z");
|
||||
|
||||
public JavaCallGraphExtractorTests()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(_fixedTime);
|
||||
_extractor = new JavaCallGraphExtractor(
|
||||
NullLogger<JavaCallGraphExtractor>.Instance,
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
#region JavaEntrypointClassifier Tests
|
||||
|
||||
[Fact]
|
||||
public void JavaEntrypointClassifier_SpringRequestMapping_DetectedAsHttpHandler()
|
||||
{
|
||||
var classifier = new JavaEntrypointClassifier();
|
||||
var classInfo = new JavaClassInfo
|
||||
{
|
||||
ClassName = "com.example.UserController",
|
||||
SuperClassName = "java.lang.Object",
|
||||
Interfaces = [],
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
Annotations = [],
|
||||
SourceFile = "UserController.java",
|
||||
Methods = []
|
||||
};
|
||||
|
||||
var method = new JavaMethodInfo
|
||||
{
|
||||
Name = "getUser",
|
||||
Descriptor = "(Ljava/lang/Long;)Lcom/example/User;",
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
LineNumber = 42,
|
||||
Annotations = ["org.springframework.web.bind.annotation.GetMapping"],
|
||||
Calls = []
|
||||
};
|
||||
|
||||
var result = classifier.Classify(classInfo, method);
|
||||
|
||||
Assert.Equal(EntrypointType.HttpHandler, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaEntrypointClassifier_SpringRestController_PublicMethodDetectedAsHttpHandler()
|
||||
{
|
||||
var classifier = new JavaEntrypointClassifier();
|
||||
var classInfo = new JavaClassInfo
|
||||
{
|
||||
ClassName = "com.example.UserController",
|
||||
SuperClassName = "java.lang.Object",
|
||||
Interfaces = [],
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
Annotations = ["org.springframework.web.bind.annotation.RestController"],
|
||||
SourceFile = "UserController.java",
|
||||
Methods = []
|
||||
};
|
||||
|
||||
var method = new JavaMethodInfo
|
||||
{
|
||||
Name = "getAllUsers",
|
||||
Descriptor = "()Ljava/util/List;",
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
LineNumber = 25,
|
||||
Annotations = [],
|
||||
Calls = []
|
||||
};
|
||||
|
||||
var result = classifier.Classify(classInfo, method);
|
||||
|
||||
Assert.Equal(EntrypointType.HttpHandler, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaEntrypointClassifier_JaxRsPath_DetectedAsHttpHandler()
|
||||
{
|
||||
var classifier = new JavaEntrypointClassifier();
|
||||
var classInfo = new JavaClassInfo
|
||||
{
|
||||
ClassName = "com.example.UserResource",
|
||||
SuperClassName = "java.lang.Object",
|
||||
Interfaces = [],
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
Annotations = ["jakarta.ws.rs.Path"],
|
||||
SourceFile = "UserResource.java",
|
||||
Methods = []
|
||||
};
|
||||
|
||||
var method = new JavaMethodInfo
|
||||
{
|
||||
Name = "getUser",
|
||||
Descriptor = "(Ljava/lang/Long;)Lcom/example/User;",
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
LineNumber = 30,
|
||||
Annotations = ["jakarta.ws.rs.GET"],
|
||||
Calls = []
|
||||
};
|
||||
|
||||
var result = classifier.Classify(classInfo, method);
|
||||
|
||||
Assert.Equal(EntrypointType.HttpHandler, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaEntrypointClassifier_SpringScheduled_DetectedAsScheduledJob()
|
||||
{
|
||||
var classifier = new JavaEntrypointClassifier();
|
||||
var classInfo = new JavaClassInfo
|
||||
{
|
||||
ClassName = "com.example.SchedulerService",
|
||||
SuperClassName = "java.lang.Object",
|
||||
Interfaces = [],
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
Annotations = [],
|
||||
SourceFile = "SchedulerService.java",
|
||||
Methods = []
|
||||
};
|
||||
|
||||
var method = new JavaMethodInfo
|
||||
{
|
||||
Name = "processExpiredTokens",
|
||||
Descriptor = "()V",
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
LineNumber = 45,
|
||||
Annotations = ["org.springframework.scheduling.annotation.Scheduled"],
|
||||
Calls = []
|
||||
};
|
||||
|
||||
var result = classifier.Classify(classInfo, method);
|
||||
|
||||
Assert.Equal(EntrypointType.ScheduledJob, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaEntrypointClassifier_KafkaListener_DetectedAsMessageHandler()
|
||||
{
|
||||
var classifier = new JavaEntrypointClassifier();
|
||||
var classInfo = new JavaClassInfo
|
||||
{
|
||||
ClassName = "com.example.OrderEventListener",
|
||||
SuperClassName = "java.lang.Object",
|
||||
Interfaces = [],
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
Annotations = [],
|
||||
SourceFile = "OrderEventListener.java",
|
||||
Methods = []
|
||||
};
|
||||
|
||||
var method = new JavaMethodInfo
|
||||
{
|
||||
Name = "handleOrderCreated",
|
||||
Descriptor = "(Lcom/example/OrderEvent;)V",
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
LineNumber = 20,
|
||||
Annotations = ["org.springframework.kafka.annotation.KafkaListener"],
|
||||
Calls = []
|
||||
};
|
||||
|
||||
var result = classifier.Classify(classInfo, method);
|
||||
|
||||
Assert.Equal(EntrypointType.MessageHandler, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaEntrypointClassifier_GrpcService_DetectedAsGrpcMethod()
|
||||
{
|
||||
var classifier = new JavaEntrypointClassifier();
|
||||
var classInfo = new JavaClassInfo
|
||||
{
|
||||
ClassName = "com.example.UserServiceGrpc",
|
||||
SuperClassName = "io.grpc.stub.AbstractStub",
|
||||
Interfaces = [],
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
Annotations = [],
|
||||
SourceFile = "UserServiceGrpc.java",
|
||||
Methods = []
|
||||
};
|
||||
|
||||
// The gRPC service annotation on method level triggers the detection
|
||||
var method = new JavaMethodInfo
|
||||
{
|
||||
Name = "getUser",
|
||||
Descriptor = "(Lcom/example/GetUserRequest;)Lcom/example/GetUserResponse;",
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
LineNumber = 50,
|
||||
Annotations = ["net.devh.boot.grpc.server.service.GrpcService"],
|
||||
Calls = []
|
||||
};
|
||||
|
||||
var result = classifier.Classify(classInfo, method);
|
||||
|
||||
Assert.Equal(EntrypointType.GrpcMethod, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaEntrypointClassifier_MainMethod_DetectedAsCliCommand()
|
||||
{
|
||||
var classifier = new JavaEntrypointClassifier();
|
||||
var classInfo = new JavaClassInfo
|
||||
{
|
||||
ClassName = "com.example.Application",
|
||||
SuperClassName = "java.lang.Object",
|
||||
Interfaces = [],
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
Annotations = [],
|
||||
SourceFile = "Application.java",
|
||||
Methods = []
|
||||
};
|
||||
|
||||
var method = new JavaMethodInfo
|
||||
{
|
||||
Name = "main",
|
||||
Descriptor = "([Ljava/lang/String;)V",
|
||||
AccessFlags = JavaAccessFlags.Public | JavaAccessFlags.Static,
|
||||
LineNumber = 10,
|
||||
Annotations = [],
|
||||
Calls = []
|
||||
};
|
||||
|
||||
var result = classifier.Classify(classInfo, method);
|
||||
|
||||
Assert.Equal(EntrypointType.CliCommand, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaEntrypointClassifier_PrivateMethod_NotDetectedAsEntrypoint()
|
||||
{
|
||||
var classifier = new JavaEntrypointClassifier();
|
||||
var classInfo = new JavaClassInfo
|
||||
{
|
||||
ClassName = "com.example.UserController",
|
||||
SuperClassName = "java.lang.Object",
|
||||
Interfaces = [],
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
Annotations = ["org.springframework.web.bind.annotation.RestController"],
|
||||
SourceFile = "UserController.java",
|
||||
Methods = []
|
||||
};
|
||||
|
||||
var method = new JavaMethodInfo
|
||||
{
|
||||
Name = "validateUser",
|
||||
Descriptor = "(Lcom/example/User;)Z",
|
||||
AccessFlags = JavaAccessFlags.Private,
|
||||
LineNumber = 100,
|
||||
Annotations = [],
|
||||
Calls = []
|
||||
};
|
||||
|
||||
var result = classifier.Classify(classInfo, method);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JavaSinkMatcher Tests
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_RuntimeExec_DetectedAsCmdExec()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
var result = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
|
||||
|
||||
Assert.Equal(SinkCategory.CmdExec, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_ProcessBuilderInit_DetectedAsCmdExec()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
var result = matcher.Match("java.lang.ProcessBuilder", "<init>", "()V");
|
||||
|
||||
Assert.Equal(SinkCategory.CmdExec, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_StatementExecute_DetectedAsSqlRaw()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
var result = matcher.Match("java.sql.Statement", "executeQuery", "(Ljava/lang/String;)Ljava/sql/ResultSet;");
|
||||
|
||||
Assert.Equal(SinkCategory.SqlRaw, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_ObjectInputStream_DetectedAsUnsafeDeser()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
var result = matcher.Match("java.io.ObjectInputStream", "readObject", "()Ljava/lang/Object;");
|
||||
|
||||
Assert.Equal(SinkCategory.UnsafeDeser, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_HttpClientExecute_DetectedAsSsrf()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
var result = matcher.Match("org.apache.http.client.HttpClient", "execute", "(Lorg/apache/http/HttpHost;Lorg/apache/http/HttpRequest;)Lorg/apache/http/HttpResponse;");
|
||||
|
||||
Assert.Equal(SinkCategory.Ssrf, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_FileWriter_DetectedAsPathTraversal()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
var result = matcher.Match("java.io.FileWriter", "<init>", "(Ljava/lang/String;)V");
|
||||
|
||||
Assert.Equal(SinkCategory.PathTraversal, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_UnknownMethod_ReturnsNull()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
var result = matcher.Match("com.example.MyService", "doSomething", "()V");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_XxeVulnerableParsing_DetectedAsXxe()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
var result = matcher.Match("javax.xml.parsers.DocumentBuilderFactory", "newInstance", "()Ljavax/xml/parsers/DocumentBuilderFactory;");
|
||||
|
||||
Assert.Equal(SinkCategory.XxeInjection, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_ScriptEngineEval_DetectedAsCodeInjection()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
var result = matcher.Match("javax.script.ScriptEngine", "eval", "(Ljava/lang/String;)Ljava/lang/Object;");
|
||||
|
||||
Assert.Equal(SinkCategory.CodeInjection, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JavaBytecodeAnalyzer Tests
|
||||
|
||||
[Fact]
|
||||
public void JavaBytecodeAnalyzer_ValidClassHeader_Parsed()
|
||||
{
|
||||
var analyzer = new JavaBytecodeAnalyzer(NullLogger<JavaCallGraphExtractor>.Instance);
|
||||
|
||||
// Minimal valid Java class file header
|
||||
// Magic: 0xCAFEBABE
|
||||
// Minor: 0, Major: 52 (Java 8)
|
||||
// Constant pool count: 1 (minimal)
|
||||
var classData = new byte[]
|
||||
{
|
||||
0xCA, 0xFE, 0xBA, 0xBE, // magic
|
||||
0x00, 0x00, // minor version
|
||||
0x00, 0x34, // major version (52 = Java 8)
|
||||
// This is incomplete but tests the magic number check
|
||||
};
|
||||
|
||||
// The parser should handle incomplete class files gracefully
|
||||
var result = analyzer.ParseClass(classData);
|
||||
|
||||
// May return null or partial result for incomplete class
|
||||
// The important thing is it doesn't throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaBytecodeAnalyzer_InvalidMagic_ReturnsNull()
|
||||
{
|
||||
var analyzer = new JavaBytecodeAnalyzer(NullLogger<JavaCallGraphExtractor>.Instance);
|
||||
|
||||
var invalidData = new byte[] { 0x00, 0x00, 0x00, 0x00 };
|
||||
|
||||
var result = analyzer.ParseClass(invalidData);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaBytecodeAnalyzer_EmptyArray_ReturnsNull()
|
||||
{
|
||||
var analyzer = new JavaBytecodeAnalyzer(NullLogger<JavaCallGraphExtractor>.Instance);
|
||||
|
||||
var result = analyzer.ParseClass([]);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_InvalidPath_ThrowsFileNotFound()
|
||||
{
|
||||
var request = new CallGraphExtractionRequest(
|
||||
ScanId: "scan-001",
|
||||
Language: "java",
|
||||
TargetPath: "/nonexistent/path/to/jar");
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => _extractor.ExtractAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_WrongLanguage_ThrowsArgumentException()
|
||||
{
|
||||
await using var temp = await TempDirectory.CreateAsync();
|
||||
|
||||
var request = new CallGraphExtractionRequest(
|
||||
ScanId: "scan-001",
|
||||
Language: "python", // Wrong language
|
||||
TargetPath: temp.Path);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _extractor.ExtractAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_EmptyDirectory_ProducesEmptySnapshot()
|
||||
{
|
||||
await using var temp = await TempDirectory.CreateAsync();
|
||||
|
||||
var request = new CallGraphExtractionRequest(
|
||||
ScanId: "scan-001",
|
||||
Language: "java",
|
||||
TargetPath: temp.Path);
|
||||
|
||||
var snapshot = await _extractor.ExtractAsync(request);
|
||||
|
||||
Assert.Equal("scan-001", snapshot.ScanId);
|
||||
Assert.Equal("java", snapshot.Language);
|
||||
Assert.NotNull(snapshot.GraphDigest);
|
||||
Assert.Empty(snapshot.Nodes);
|
||||
Assert.Empty(snapshot.Edges);
|
||||
Assert.Empty(snapshot.EntrypointIds);
|
||||
Assert.Empty(snapshot.SinkIds);
|
||||
Assert.Equal(_fixedTime, snapshot.ExtractedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extractor_Language_IsJava()
|
||||
{
|
||||
Assert.Equal("java", _extractor.Language);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Verification Tests (JCG-020)
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_SamePath_ProducesSameDigest()
|
||||
{
|
||||
// Arrange: Create a temp directory
|
||||
await using var temp = await TempDirectory.CreateAsync();
|
||||
|
||||
var request = new CallGraphExtractionRequest(
|
||||
ScanId: "scan-determinism-1",
|
||||
Language: "java",
|
||||
TargetPath: temp.Path);
|
||||
|
||||
// Act: Extract twice with same input
|
||||
var snapshot1 = await _extractor.ExtractAsync(request);
|
||||
var snapshot2 = await _extractor.ExtractAsync(request);
|
||||
|
||||
// Assert: Same digest
|
||||
Assert.Equal(snapshot1.GraphDigest, snapshot2.GraphDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_DifferentScanId_SameNodesAndEdges()
|
||||
{
|
||||
// Arrange: Create a temp directory
|
||||
await using var temp = await TempDirectory.CreateAsync();
|
||||
|
||||
var request1 = new CallGraphExtractionRequest(
|
||||
ScanId: "scan-a",
|
||||
Language: "java",
|
||||
TargetPath: temp.Path);
|
||||
|
||||
var request2 = new CallGraphExtractionRequest(
|
||||
ScanId: "scan-b",
|
||||
Language: "java",
|
||||
TargetPath: temp.Path);
|
||||
|
||||
// Act: Extract with different scan IDs
|
||||
var snapshot1 = await _extractor.ExtractAsync(request1);
|
||||
var snapshot2 = await _extractor.ExtractAsync(request2);
|
||||
|
||||
// Assert: Same graph content (nodes, edges, digests match)
|
||||
Assert.Equal(snapshot1.Nodes.Length, snapshot2.Nodes.Length);
|
||||
Assert.Equal(snapshot1.Edges.Length, snapshot2.Edges.Length);
|
||||
Assert.Equal(snapshot1.GraphDigest, snapshot2.GraphDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNodeId_SameInputs_ProducesIdenticalIds()
|
||||
{
|
||||
// Act: Build node IDs multiple times with same inputs
|
||||
var id1 = BuildTestNodeId("com.example.Service", "doWork", "(Ljava/lang/String;)V");
|
||||
var id2 = BuildTestNodeId("com.example.Service", "doWork", "(Ljava/lang/String;)V");
|
||||
|
||||
// Assert: Identical
|
||||
Assert.Equal(id1, id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNodeId_DifferentDescriptors_ProducesDifferentIds()
|
||||
{
|
||||
// Act: Build node IDs with different descriptors (overloaded methods)
|
||||
var id1 = BuildTestNodeId("com.example.Service", "process", "(Ljava/lang/String;)V");
|
||||
var id2 = BuildTestNodeId("com.example.Service", "process", "(I)V");
|
||||
|
||||
// Assert: Different (handles overloading)
|
||||
Assert.NotEqual(id1, id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaEntrypointClassifier_SameInput_AlwaysSameResult()
|
||||
{
|
||||
var classifier = new JavaEntrypointClassifier();
|
||||
|
||||
var classInfo = new JavaClassInfo
|
||||
{
|
||||
ClassName = "com.example.Controller",
|
||||
SuperClassName = "java.lang.Object",
|
||||
Interfaces = [],
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
Annotations = ["org.springframework.web.bind.annotation.RestController"],
|
||||
SourceFile = "Controller.java",
|
||||
Methods = []
|
||||
};
|
||||
|
||||
var method = new JavaMethodInfo
|
||||
{
|
||||
Name = "handleRequest",
|
||||
Descriptor = "()V",
|
||||
AccessFlags = JavaAccessFlags.Public,
|
||||
LineNumber = 10,
|
||||
Annotations = [],
|
||||
Calls = []
|
||||
};
|
||||
|
||||
// Act: Classify multiple times
|
||||
var result1 = classifier.Classify(classInfo, method);
|
||||
var result2 = classifier.Classify(classInfo, method);
|
||||
var result3 = classifier.Classify(classInfo, method);
|
||||
|
||||
// Assert: All results identical
|
||||
Assert.Equal(result1, result2);
|
||||
Assert.Equal(result2, result3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaSinkMatcher_SameInput_AlwaysSameResult()
|
||||
{
|
||||
var matcher = new JavaSinkMatcher();
|
||||
|
||||
// Act: Match multiple times
|
||||
var result1 = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
|
||||
var result2 = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
|
||||
var result3 = matcher.Match("java.lang.Runtime", "exec", "(Ljava/lang/String;)Ljava/lang/Process;");
|
||||
|
||||
// Assert: All results identical
|
||||
Assert.Equal(result1, result2);
|
||||
Assert.Equal(result2, result3);
|
||||
Assert.Equal(SinkCategory.CmdExec, result1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to match the internal BuildNodeId logic for testing.
|
||||
/// </summary>
|
||||
private static string BuildTestNodeId(string className, string methodName, string descriptor)
|
||||
{
|
||||
return $"java:{className}.{methodName}{descriptor}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _instant;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset instant)
|
||||
{
|
||||
_instant = instant;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _instant;
|
||||
}
|
||||
|
||||
private sealed class TempDirectory : IAsyncDisposable
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
private TempDirectory(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public static Task<TempDirectory> CreateAsync()
|
||||
{
|
||||
var root = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"stella_java_callgraph_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
return Task.FromResult(new TempDirectory(root));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort cleanup
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// JavaScriptCallGraphExtractorTests.cs
|
||||
// Sprint: SPRINT_3610_0003_0001_nodejs_callgraph (NCG-012)
|
||||
// Description: Unit tests for JavaScriptCallGraphExtractor.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.CallGraph.JavaScript;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for JavaScript/TypeScript call graph extraction.
|
||||
/// Tests entrypoint classification, sink matching, and extraction logic.
|
||||
/// </summary>
|
||||
public sealed class JavaScriptCallGraphExtractorTests : IAsyncLifetime
|
||||
{
|
||||
private readonly JavaScriptCallGraphExtractor _extractor;
|
||||
private readonly DateTimeOffset _fixedTime = new(2025, 12, 19, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public JavaScriptCallGraphExtractorTests()
|
||||
{
|
||||
_extractor = new JavaScriptCallGraphExtractor(
|
||||
NullLogger<JavaScriptCallGraphExtractor>.Instance,
|
||||
new FixedTimeProvider(_fixedTime));
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
#region Entrypoint Classifier Tests
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_ExpressHandler_ReturnsHttpHandler()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::handler",
|
||||
Name = "handler",
|
||||
FullName = "routes.handler",
|
||||
Module = "express",
|
||||
IsRouteHandler = true,
|
||||
Line = 10,
|
||||
IsExported = true
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(EntrypointType.HttpHandler, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_FastifyRoute_ReturnsHttpHandler()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::getUsers",
|
||||
Name = "getUsers",
|
||||
FullName = "routes.getUsers",
|
||||
Module = "fastify",
|
||||
IsRouteHandler = true,
|
||||
HttpMethod = "GET",
|
||||
HttpRoute = "/users",
|
||||
Line = 15,
|
||||
IsExported = true
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(EntrypointType.HttpHandler, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_LambdaHandler_ReturnsLambda()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::handler",
|
||||
Name = "handler",
|
||||
FullName = "lambda.handler",
|
||||
Module = "aws-lambda",
|
||||
Line = 5,
|
||||
IsExported = true
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(EntrypointType.Lambda, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_AzureFunction_ReturnsLambda()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::httpTrigger",
|
||||
Name = "httpTrigger",
|
||||
FullName = "function.httpTrigger",
|
||||
Module = "@azure/functions",
|
||||
Line = 10,
|
||||
IsExported = true
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(EntrypointType.Lambda, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_CommanderCli_ReturnsCliCommand()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::action",
|
||||
Name = "action",
|
||||
FullName = "cli.action",
|
||||
Module = "commander",
|
||||
Line = 20,
|
||||
IsExported = false
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(EntrypointType.CliCommand, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_SocketIo_ReturnsWebSocketHandler()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::onConnection",
|
||||
Name = "onConnection",
|
||||
FullName = "socket.onConnection",
|
||||
Module = "socket.io",
|
||||
Line = 30,
|
||||
IsExported = false
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(EntrypointType.WebSocketHandler, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_Kafkajs_ReturnsMessageHandler()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::consumer",
|
||||
Name = "consumer",
|
||||
FullName = "kafka.consumer",
|
||||
Module = "kafkajs",
|
||||
Line = 40,
|
||||
IsExported = true
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(EntrypointType.MessageHandler, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_NodeCron_ReturnsScheduledJob()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::cronJob",
|
||||
Name = "cronJob",
|
||||
FullName = "scheduler.cronJob",
|
||||
Module = "node-cron",
|
||||
Line = 50,
|
||||
IsExported = false
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(EntrypointType.ScheduledJob, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_GraphQL_ReturnsHttpHandler()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::resolver",
|
||||
Name = "resolver",
|
||||
FullName = "graphql.resolver",
|
||||
Module = "@apollo/server",
|
||||
IsRouteHandler = true,
|
||||
Line = 60,
|
||||
IsExported = true
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(EntrypointType.HttpHandler, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_NoMatch_ReturnsNull()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::helperFn",
|
||||
Name = "helperFn",
|
||||
FullName = "utils.helperFn",
|
||||
Module = "utils",
|
||||
Line = 100,
|
||||
IsExported = false
|
||||
};
|
||||
|
||||
var result = classifier.Classify(func);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sink Matcher Tests
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_ChildProcessExec_ReturnsCmdExec()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("child_process", "exec");
|
||||
|
||||
Assert.Equal(SinkCategory.CmdExec, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_ChildProcessSpawn_ReturnsCmdExec()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("child_process", "spawn");
|
||||
|
||||
Assert.Equal(SinkCategory.CmdExec, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_ChildProcessFork_ReturnsCmdExec()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("child_process", "fork");
|
||||
|
||||
Assert.Equal(SinkCategory.CmdExec, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_MysqlQuery_ReturnsSqlRaw()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("mysql", "query");
|
||||
|
||||
Assert.Equal(SinkCategory.SqlRaw, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_PgQuery_ReturnsSqlRaw()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("pg", "query");
|
||||
|
||||
Assert.Equal(SinkCategory.SqlRaw, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_KnexRaw_ReturnsSqlRaw()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("knex", "raw");
|
||||
|
||||
Assert.Equal(SinkCategory.SqlRaw, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_FsReadFile_ReturnsPathTraversal()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("fs", "readFile");
|
||||
|
||||
Assert.Equal(SinkCategory.PathTraversal, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_FsWriteFile_ReturnsPathTraversal()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("fs", "writeFile");
|
||||
|
||||
Assert.Equal(SinkCategory.PathTraversal, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_AxiosGet_ReturnsSsrf()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("axios", "get");
|
||||
|
||||
Assert.Equal(SinkCategory.Ssrf, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_HttpRequest_ReturnsSsrf()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("http", "request");
|
||||
|
||||
Assert.Equal(SinkCategory.Ssrf, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_JsYamlLoad_ReturnsUnsafeDeser()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("js-yaml", "load");
|
||||
|
||||
Assert.Equal(SinkCategory.UnsafeDeser, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_Eval_ReturnsCodeInjection()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("eval", "eval");
|
||||
|
||||
Assert.Equal(SinkCategory.CodeInjection, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_VmRunInContext_ReturnsCodeInjection()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("vm", "runInContext");
|
||||
|
||||
Assert.Equal(SinkCategory.CodeInjection, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_EjsRender_ReturnsTemplateInjection()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("ejs", "render");
|
||||
|
||||
Assert.Equal(SinkCategory.TemplateInjection, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_NoMatch_ReturnsNull()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result = matcher.Match("lodash", "map");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extractor Tests
|
||||
|
||||
[Fact]
|
||||
public void Extractor_Language_IsJavascript()
|
||||
{
|
||||
Assert.Equal("javascript", _extractor.Language);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_MissingPackageJson_ThrowsFileNotFound()
|
||||
{
|
||||
await using var temp = await TempDirectory.CreateAsync();
|
||||
|
||||
var request = new CallGraphExtractionRequest(
|
||||
ScanId: "scan-001",
|
||||
Language: "javascript",
|
||||
TargetPath: temp.Path);
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => _extractor.ExtractAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_WithPackageJson_ReturnsSnapshot()
|
||||
{
|
||||
await using var temp = await TempDirectory.CreateAsync();
|
||||
|
||||
// Create a minimal package.json
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "test-app",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(temp.Path, "package.json"), packageJson);
|
||||
|
||||
var request = new CallGraphExtractionRequest(
|
||||
ScanId: "scan-001",
|
||||
Language: "javascript",
|
||||
TargetPath: temp.Path);
|
||||
|
||||
var snapshot = await _extractor.ExtractAsync(request);
|
||||
|
||||
Assert.Equal("scan-001", snapshot.ScanId);
|
||||
Assert.Equal("javascript", snapshot.Language);
|
||||
Assert.NotNull(snapshot.GraphDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_SameInput_ProducesSameDigest()
|
||||
{
|
||||
await using var temp = await TempDirectory.CreateAsync();
|
||||
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "test-app",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(temp.Path, "package.json"), packageJson);
|
||||
|
||||
var request = new CallGraphExtractionRequest(
|
||||
ScanId: "scan-001",
|
||||
Language: "javascript",
|
||||
TargetPath: temp.Path);
|
||||
|
||||
var snapshot1 = await _extractor.ExtractAsync(request);
|
||||
var snapshot2 = await _extractor.ExtractAsync(request);
|
||||
|
||||
Assert.Equal(snapshot1.GraphDigest, snapshot2.GraphDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointClassifier_SameInput_AlwaysSameResult()
|
||||
{
|
||||
var classifier = new JsEntrypointClassifier();
|
||||
|
||||
var func = new JsFunctionInfo
|
||||
{
|
||||
NodeId = "test::handler",
|
||||
Name = "handler",
|
||||
FullName = "routes.handler",
|
||||
Module = "express",
|
||||
IsRouteHandler = true,
|
||||
Line = 10,
|
||||
IsExported = true
|
||||
};
|
||||
|
||||
var result1 = classifier.Classify(func);
|
||||
var result2 = classifier.Classify(func);
|
||||
var result3 = classifier.Classify(func);
|
||||
|
||||
Assert.Equal(result1, result2);
|
||||
Assert.Equal(result2, result3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsSinkMatcher_SameInput_AlwaysSameResult()
|
||||
{
|
||||
var matcher = new JsSinkMatcher();
|
||||
|
||||
var result1 = matcher.Match("child_process", "exec");
|
||||
var result2 = matcher.Match("child_process", "exec");
|
||||
var result3 = matcher.Match("child_process", "exec");
|
||||
|
||||
Assert.Equal(result1, result2);
|
||||
Assert.Equal(result2, result3);
|
||||
Assert.Equal(SinkCategory.CmdExec, result1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _instant;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset instant)
|
||||
{
|
||||
_instant = instant;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _instant;
|
||||
}
|
||||
|
||||
private sealed class TempDirectory : IAsyncDisposable
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
private TempDirectory(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public static Task<TempDirectory> CreateAsync()
|
||||
{
|
||||
var root = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"stella_js_callgraph_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
return Task.FromResult(new TempDirectory(root));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort cleanup
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NodeCallGraphExtractorTests.cs
|
||||
// Sprint: SPRINT_3610_0003_0001_nodejs_callgraph
|
||||
// Description: Unit tests for Node.js/JavaScript call graph extraction.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Scanner.CallGraph.Node;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
public class NodeCallGraphExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public void BabelResultParser_ParsesValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"module": "my-app",
|
||||
"version": "1.0.0",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "js:my-app/src/index.handleRequest",
|
||||
"package": "my-app",
|
||||
"name": "handleRequest",
|
||||
"visibility": "public"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "js:my-app/src/index.handleRequest",
|
||||
"to": "js:external/express.Router",
|
||||
"kind": "direct"
|
||||
}
|
||||
],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "js:my-app/src/index.handleRequest",
|
||||
"type": "http_handler",
|
||||
"route": "/api/users",
|
||||
"method": "GET"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-app", result.Module);
|
||||
Assert.Equal("1.0.0", result.Version);
|
||||
Assert.Single(result.Nodes);
|
||||
Assert.Single(result.Edges);
|
||||
Assert.Single(result.Entrypoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BabelResultParser_ParsesNodeWithPosition()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"module": "test",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "js:test/app.main",
|
||||
"package": "test",
|
||||
"name": "main",
|
||||
"position": {
|
||||
"file": "app.js",
|
||||
"line": 10,
|
||||
"column": 5
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
"entrypoints": []
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Nodes);
|
||||
var node = result.Nodes[0];
|
||||
Assert.NotNull(node.Position);
|
||||
Assert.Equal("app.js", node.Position.File);
|
||||
Assert.Equal(10, node.Position.Line);
|
||||
Assert.Equal(5, node.Position.Column);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BabelResultParser_ParsesEdgeWithSite()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"module": "test",
|
||||
"nodes": [],
|
||||
"edges": [
|
||||
{
|
||||
"from": "js:test/a.foo",
|
||||
"to": "js:test/b.bar",
|
||||
"kind": "callback",
|
||||
"site": {
|
||||
"file": "a.js",
|
||||
"line": 25
|
||||
}
|
||||
}
|
||||
],
|
||||
"entrypoints": []
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Edges);
|
||||
var edge = result.Edges[0];
|
||||
Assert.Equal("callback", edge.Kind);
|
||||
Assert.NotNull(edge.Site);
|
||||
Assert.Equal("a.js", edge.Site.File);
|
||||
Assert.Equal(25, edge.Site.Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BabelResultParser_ThrowsOnEmptyInput()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => BabelResultParser.Parse(""));
|
||||
Assert.Throws<ArgumentException>(() => BabelResultParser.Parse(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BabelResultParser_ParsesNdjson()
|
||||
{
|
||||
// Arrange
|
||||
var ndjson = """
|
||||
{"type": "progress", "percent": 50}
|
||||
{"type": "progress", "percent": 100}
|
||||
{"module": "app", "nodes": [], "edges": [], "entrypoints": []}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.ParseNdjson(ndjson);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("app", result.Module);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsEntrypointInfo_HasCorrectProperties()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"module": "api",
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"entrypoints": [
|
||||
{
|
||||
"id": "js:api/routes.getUsers",
|
||||
"type": "http_handler",
|
||||
"route": "/users/:id",
|
||||
"method": "GET"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = BabelResultParser.Parse(json);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Entrypoints);
|
||||
var ep = result.Entrypoints[0];
|
||||
Assert.Equal("js:api/routes.getUsers", ep.Id);
|
||||
Assert.Equal("http_handler", ep.Type);
|
||||
Assert.Equal("/users/:id", ep.Route);
|
||||
Assert.Equal("GET", ep.Method);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user