All checks were successful
Complete pentest feature overhaul: SSE streaming, session-persistent browser tool (CDP), AES-256 credential encryption, auto-screenshots in reports, code-level remediation correlation, SAST triage chunking, context window optimization, test user cleanup (Keycloak/Auth0/Okta), wizard dropdowns, attack chain improvements, architecture docs with Mermaid diagrams. Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #16
194 lines
6.7 KiB
Rust
194 lines
6.7 KiB
Rust
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#"<p style="color: var(--text-muted);">No attack chain steps recorded.</p>"#.to_string()
|
|
} else {
|
|
build_chain_html(chain)
|
|
};
|
|
|
|
format!(
|
|
r##"<!-- ═══════════════ 4. ATTACK CHAIN ═══════════════ -->
|
|
<div class="page-break"></div>
|
|
<h2><span class="section-num">4.</span> Attack Chain Timeline</h2>
|
|
|
|
<p>
|
|
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.
|
|
</p>
|
|
|
|
<div style="margin-top: 16px;">
|
|
{chain_section}
|
|
</div>"##
|
|
)
|
|
}
|
|
|
|
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<String, usize> = std::collections::HashMap::new();
|
|
let mut queue: std::collections::VecDeque<String> = 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#"<div class="phase-block">
|
|
<div class="phase-header">
|
|
<span class="phase-num">Phase {}</span>
|
|
<span class="phase-label">{}</span>
|
|
<span class="phase-count">{} step{}</span>
|
|
</div>
|
|
<div class="phase-steps">"#,
|
|
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#"<span class="step-findings">{} finding{}</span>"#,
|
|
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#"<span class="step-risk {risk_class}">Risk: {r}</span>"#)
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let reasoning_html = if node.llm_reasoning.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
r#"<div class="step-reasoning">{}</div>"#,
|
|
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#"<div class="step-screenshot"><img src="data:image/png;base64,{b64}" alt="Browser screenshot" style="max-width:100%;border:1px solid #e2e8f0;border-radius:6px;margin-top:8px;"/></div>"#
|
|
)
|
|
})
|
|
.unwrap_or_default()
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
chain_html.push_str(&format!(
|
|
r#"<div class="step-row">
|
|
<div class="step-num">{num}</div>
|
|
<div class="step-connector"></div>
|
|
<div class="step-content">
|
|
<div class="step-header">
|
|
<span class="step-tool">{tool_name}</span>
|
|
<span class="step-status {status_class}">{status_label}</span>
|
|
{findings_badge}
|
|
{risk_badge}
|
|
</div>
|
|
{reasoning_html}
|
|
{screenshot_html}
|
|
</div>
|
|
</div>"#,
|
|
num = i + 1,
|
|
tool_name = html_escape(&node.tool_name),
|
|
));
|
|
}
|
|
|
|
chain_html.push_str("</div></div>");
|
|
}
|
|
|
|
chain_html
|
|
}
|