use super::html_escape; use compliance_core::models::pentest::AttackChainNode; pub(super) fn attack_chain(chain: &[AttackChainNode]) -> String { let chain_section = if chain.is_empty() { r#"

No attack chain steps recorded.

"#.to_string() } else { build_chain_html(chain) }; format!( r##"

4. Attack Chain Timeline

The following sequence shows each tool invocation made by the AI orchestrator during the assessment, grouped by phase. Each step includes the tool's name, execution status, and the AI's reasoning for choosing that action.

{chain_section}
"## ) } fn build_chain_html(chain: &[AttackChainNode]) -> String { let mut chain_html = String::new(); // Compute phases via BFS from root nodes let mut phase_map: std::collections::HashMap = std::collections::HashMap::new(); let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); for node in chain { if node.parent_node_ids.is_empty() { let nid = node.node_id.clone(); if !nid.is_empty() { phase_map.insert(nid.clone(), 0); queue.push_back(nid); } } } while let Some(nid) = queue.pop_front() { let parent_phase = phase_map.get(&nid).copied().unwrap_or(0); for node in chain { if node.parent_node_ids.contains(&nid) { let child_id = node.node_id.clone(); if !child_id.is_empty() && !phase_map.contains_key(&child_id) { phase_map.insert(child_id.clone(), parent_phase + 1); queue.push_back(child_id); } } } } // Assign phase 0 to any unassigned nodes for node in chain { let nid = node.node_id.clone(); if !nid.is_empty() && !phase_map.contains_key(&nid) { phase_map.insert(nid, 0); } } // Group nodes by phase let max_phase = phase_map.values().copied().max().unwrap_or(0); let phase_labels = [ "Reconnaissance", "Enumeration", "Exploitation", "Validation", "Post-Exploitation", ]; for phase_idx in 0..=max_phase { let phase_nodes: Vec<&AttackChainNode> = chain .iter() .filter(|n| { let nid = n.node_id.clone(); phase_map.get(&nid).copied().unwrap_or(0) == phase_idx }) .collect(); if phase_nodes.is_empty() { continue; } let label = if phase_idx < phase_labels.len() { phase_labels[phase_idx] } else { "Additional Testing" }; chain_html.push_str(&format!( r#"
Phase {} {} {} step{}
"#, phase_idx + 1, label, phase_nodes.len(), if phase_nodes.len() == 1 { "" } else { "s" }, )); for (i, node) in phase_nodes.iter().enumerate() { let status_label = format!("{:?}", node.status); let status_class = match status_label.to_lowercase().as_str() { "completed" => "step-completed", "failed" => "step-failed", _ => "step-running", }; let findings_badge = if !node.findings_produced.is_empty() { format!( r#"{} finding{}"#, node.findings_produced.len(), if node.findings_produced.len() == 1 { "" } else { "s" }, ) } else { String::new() }; let risk_badge = node .risk_score .map(|r| { let risk_class = if r >= 70 { "risk-high" } else if r >= 40 { "risk-med" } else { "risk-low" }; format!(r#"Risk: {r}"#) }) .unwrap_or_default(); let reasoning_html = if node.llm_reasoning.is_empty() { String::new() } else { format!( r#"
{}
"#, html_escape(&node.llm_reasoning) ) }; // Render inline screenshot if this is a browser screenshot action let screenshot_html = if node.tool_name == "browser" { node.tool_output .as_ref() .and_then(|out| out.get("screenshot_base64")) .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(|b64| { format!( r#"
Browser screenshot
"# ) }) .unwrap_or_default() } else { String::new() }; chain_html.push_str(&format!( r#"
{num}
{tool_name} {status_label} {findings_badge} {risk_badge}
{reasoning_html} {screenshot_html}
"#, num = i + 1, tool_name = html_escape(&node.tool_name), )); } chain_html.push_str("
"); } chain_html }