Add DAST, graph modules, toast notifications, and dashboard enhancements
Add DAST scanning and code knowledge graph features across the stack: - compliance-dast and compliance-graph workspace crates - Agent API handlers and routes for DAST targets/scans and graph builds - Core models and traits for DAST and graph domains - Dashboard pages for DAST targets/findings/overview and graph explorer/impact - Toast notification system with auto-dismiss for async action feedback - Button click animations and disabled states for better UX Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
03ee69834d
commit
cea8f59e10
@@ -0,0 +1,219 @@
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
use compliance_core::models::graph::ImpactAnalysis;
|
||||
use petgraph::graph::NodeIndex;
|
||||
use petgraph::visit::EdgeRef;
|
||||
use petgraph::Direction;
|
||||
|
||||
use super::engine::CodeGraph;
|
||||
|
||||
/// Analyzes the impact/blast radius of findings within a code graph
|
||||
pub struct ImpactAnalyzer<'a> {
|
||||
code_graph: &'a CodeGraph,
|
||||
}
|
||||
|
||||
impl<'a> ImpactAnalyzer<'a> {
|
||||
pub fn new(code_graph: &'a CodeGraph) -> Self {
|
||||
Self { code_graph }
|
||||
}
|
||||
|
||||
/// Compute impact analysis for a finding at the given file path and line number
|
||||
pub fn analyze(
|
||||
&self,
|
||||
repo_id: &str,
|
||||
finding_id: &str,
|
||||
graph_build_id: &str,
|
||||
file_path: &str,
|
||||
line_number: Option<u32>,
|
||||
) -> ImpactAnalysis {
|
||||
let mut analysis =
|
||||
ImpactAnalysis::new(repo_id.to_string(), finding_id.to_string(), graph_build_id.to_string());
|
||||
|
||||
// Find the node containing the finding
|
||||
let target_node = self.find_node_at_location(file_path, line_number);
|
||||
let target_idx = match target_node {
|
||||
Some(idx) => idx,
|
||||
None => return analysis,
|
||||
};
|
||||
|
||||
// BFS forward: compute blast radius (what this node affects)
|
||||
let forward_reachable = self.bfs_reachable(target_idx, Direction::Outgoing);
|
||||
analysis.blast_radius = forward_reachable.len() as u32;
|
||||
|
||||
// BFS backward: find entry points that reach this node
|
||||
let backward_reachable = self.bfs_reachable(target_idx, Direction::Incoming);
|
||||
|
||||
// Find affected entry points
|
||||
for &idx in &backward_reachable {
|
||||
if let Some(node) = self.get_node_by_index(idx) {
|
||||
if node.is_entry_point {
|
||||
analysis
|
||||
.affected_entry_points
|
||||
.push(node.qualified_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract call chains from entry points to the target (limited depth)
|
||||
for entry_name in &analysis.affected_entry_points.clone() {
|
||||
if let Some(&entry_idx) = self.code_graph.node_map.get(entry_name) {
|
||||
if let Some(chain) = self.find_path(entry_idx, target_idx, 10) {
|
||||
analysis.call_chains.push(chain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct callers (incoming edges to target)
|
||||
for edge in self
|
||||
.code_graph
|
||||
.graph
|
||||
.edges_directed(target_idx, Direction::Incoming)
|
||||
{
|
||||
if let Some(node) = self.get_node_by_index(edge.source()) {
|
||||
analysis.direct_callers.push(node.qualified_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Direct callees (outgoing edges from target)
|
||||
for edge in self.code_graph.graph.edges(target_idx) {
|
||||
if let Some(node) = self.get_node_by_index(edge.target()) {
|
||||
analysis.direct_callees.push(node.qualified_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Affected communities
|
||||
let mut affected_comms: HashSet<u32> = HashSet::new();
|
||||
for &idx in forward_reachable.iter().chain(std::iter::once(&target_idx)) {
|
||||
if let Some(node) = self.get_node_by_index(idx) {
|
||||
if let Some(cid) = node.community_id {
|
||||
affected_comms.insert(cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
analysis.affected_communities = affected_comms.into_iter().collect();
|
||||
analysis.affected_communities.sort();
|
||||
|
||||
analysis
|
||||
}
|
||||
|
||||
/// Find the graph node at a given file/line location
|
||||
fn find_node_at_location(&self, file_path: &str, line_number: Option<u32>) -> Option<NodeIndex> {
|
||||
let mut best: Option<(NodeIndex, u32)> = None; // (index, line_span)
|
||||
|
||||
for node in &self.code_graph.nodes {
|
||||
if node.file_path != file_path {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(line) = line_number {
|
||||
if line >= node.start_line && line <= node.end_line {
|
||||
let span = node.end_line - node.start_line;
|
||||
// Prefer the narrowest containing node
|
||||
if best.is_none() || span < best.as_ref().map(|b| b.1).unwrap_or(u32::MAX) {
|
||||
if let Some(gi) = node.graph_index {
|
||||
best = Some((NodeIndex::new(gi as usize), span));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No line number, use file node
|
||||
if node.kind == compliance_core::models::graph::CodeNodeKind::File {
|
||||
if let Some(gi) = node.graph_index {
|
||||
return Some(NodeIndex::new(gi as usize));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(idx, _)| idx)
|
||||
}
|
||||
|
||||
/// BFS to find all reachable nodes in a given direction
|
||||
fn bfs_reachable(&self, start: NodeIndex, direction: Direction) -> HashSet<NodeIndex> {
|
||||
let mut visited = HashSet::new();
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back(start);
|
||||
|
||||
while let Some(current) = queue.pop_front() {
|
||||
if !visited.insert(current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let neighbors: Vec<NodeIndex> = match direction {
|
||||
Direction::Outgoing => self
|
||||
.code_graph
|
||||
.graph
|
||||
.edges(current)
|
||||
.map(|e| e.target())
|
||||
.collect(),
|
||||
Direction::Incoming => self
|
||||
.code_graph
|
||||
.graph
|
||||
.edges_directed(current, Direction::Incoming)
|
||||
.map(|e| e.source())
|
||||
.collect(),
|
||||
};
|
||||
|
||||
for neighbor in neighbors {
|
||||
if !visited.contains(&neighbor) {
|
||||
queue.push_back(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visited.remove(&start);
|
||||
visited
|
||||
}
|
||||
|
||||
/// Find a path from source to target (BFS, limited depth)
|
||||
fn find_path(
|
||||
&self,
|
||||
from: NodeIndex,
|
||||
to: NodeIndex,
|
||||
max_depth: usize,
|
||||
) -> Option<Vec<String>> {
|
||||
let mut visited = HashSet::new();
|
||||
let mut queue: VecDeque<(NodeIndex, Vec<NodeIndex>)> = VecDeque::new();
|
||||
queue.push_back((from, vec![from]));
|
||||
|
||||
while let Some((current, path)) = queue.pop_front() {
|
||||
if current == to {
|
||||
return Some(
|
||||
path.iter()
|
||||
.filter_map(|&idx| {
|
||||
self.get_node_by_index(idx)
|
||||
.map(|n| n.qualified_name.clone())
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
if path.len() >= max_depth {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !visited.insert(current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for edge in self.code_graph.graph.edges(current) {
|
||||
let next = edge.target();
|
||||
if !visited.contains(&next) {
|
||||
let mut new_path = path.clone();
|
||||
new_path.push(next);
|
||||
queue.push_back((next, new_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn get_node_by_index(&self, idx: NodeIndex) -> Option<&compliance_core::models::graph::CodeNode> {
|
||||
let target_gi = idx.index() as u32;
|
||||
self.code_graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.graph_index == Some(target_gi))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user