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" />
|
||||
|
||||
Reference in New Issue
Block a user