Files
compliance-scanner-agent/compliance-dashboard/src/components/attack_chain/helpers.rs
Sharang Parnerkar c461faa2fb
All checks were successful
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 7s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Successful in 2s
CI / Deploy MCP (push) Successful in 2s
feat: pentest onboarding — streaming, browser automation, reports, user cleanup (#16)
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
2026-03-17 20:32:20 +00:00

294 lines
8.0 KiB
Rust

use std::collections::{HashMap, VecDeque};
/// Get category CSS class from tool name
pub(crate) fn tool_category(name: &str) -> &'static str {
let lower = name.to_lowercase();
if lower.contains("recon") {
return "recon";
}
if lower.contains("openapi") || lower.contains("api") || lower.contains("swagger") {
return "api";
}
if lower.contains("header") {
return "headers";
}
if lower.contains("csp") {
return "csp";
}
if lower.contains("cookie") {
return "cookies";
}
if lower.contains("log") || lower.contains("console") {
return "logs";
}
if lower.contains("rate") || lower.contains("limit") {
return "ratelimit";
}
if lower.contains("cors") {
return "cors";
}
if lower.contains("tls") || lower.contains("ssl") {
return "tls";
}
if lower.contains("redirect") {
return "redirect";
}
if lower.contains("dns")
|| lower.contains("dmarc")
|| lower.contains("email")
|| lower.contains("spf")
{
return "email";
}
if lower.contains("auth")
|| lower.contains("jwt")
|| lower.contains("token")
|| lower.contains("session")
{
return "auth";
}
if lower.contains("xss") {
return "xss";
}
if lower.contains("sql") || lower.contains("sqli") {
return "sqli";
}
if lower.contains("ssrf") {
return "ssrf";
}
if lower.contains("idor") {
return "idor";
}
if lower.contains("fuzz") {
return "fuzzer";
}
if lower.contains("cve") || lower.contains("exploit") {
return "cve";
}
"default"
}
/// Get emoji icon from tool category
pub(crate) fn tool_emoji(cat: &str) -> &'static str {
match cat {
"recon" => "\u{1F50D}",
"api" => "\u{1F517}",
"headers" => "\u{1F6E1}",
"csp" => "\u{1F6A7}",
"cookies" => "\u{1F36A}",
"logs" => "\u{1F4DD}",
"ratelimit" => "\u{23F1}",
"cors" => "\u{1F30D}",
"tls" => "\u{1F510}",
"redirect" => "\u{21AA}",
"email" => "\u{1F4E7}",
"auth" => "\u{1F512}",
"xss" => "\u{26A1}",
"sqli" => "\u{1F489}",
"ssrf" => "\u{1F310}",
"idor" => "\u{1F511}",
"fuzzer" => "\u{1F9EA}",
"cve" => "\u{1F4A3}",
_ => "\u{1F527}",
}
}
/// Compute display label for category
pub(crate) fn cat_label(cat: &str) -> &'static str {
match cat {
"recon" => "Recon",
"api" => "API",
"headers" => "Headers",
"csp" => "CSP",
"cookies" => "Cookies",
"logs" => "Logs",
"ratelimit" => "Rate Limit",
"cors" => "CORS",
"tls" => "TLS",
"redirect" => "Redirect",
"email" => "Email/DNS",
"auth" => "Auth",
"xss" => "XSS",
"sqli" => "SQLi",
"ssrf" => "SSRF",
"idor" => "IDOR",
"fuzzer" => "Fuzzer",
"cve" => "CVE",
_ => "Other",
}
}
/// Maximum number of display phases — deeper iterations are merged into the last.
const MAX_PHASES: usize = 8;
/// Phase name heuristic based on phase index (not raw BFS depth)
pub(crate) fn phase_name(phase_idx: usize) -> &'static str {
match phase_idx {
0 => "Reconnaissance",
1 => "Analysis",
2 => "Boundary Testing",
3 => "Injection & Exploitation",
4 => "Authentication Testing",
5 => "Validation",
6 => "Deep Scan",
_ => "Final",
}
}
/// Short label for phase rail
pub(crate) fn phase_short_name(phase_idx: usize) -> &'static str {
match phase_idx {
0 => "Recon",
1 => "Analysis",
2 => "Boundary",
3 => "Exploit",
4 => "Auth",
5 => "Validate",
6 => "Deep",
_ => "Final",
}
}
/// Compute BFS phases from attack chain nodes
pub(crate) fn compute_phases(steps: &[serde_json::Value]) -> Vec<Vec<usize>> {
let node_ids: Vec<String> = steps
.iter()
.map(|s| {
s.get("node_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
})
.collect();
let id_to_idx: HashMap<String, usize> = node_ids
.iter()
.enumerate()
.map(|(i, id)| (id.clone(), i))
.collect();
// Compute depth via BFS
let mut depths = vec![usize::MAX; steps.len()];
let mut queue = VecDeque::new();
// Root nodes: those with no parents or parents not in the set
for (i, step) in steps.iter().enumerate() {
let parents = step
.get("parent_node_ids")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|p| p.as_str())
.filter(|p| id_to_idx.contains_key(*p))
.count()
})
.unwrap_or(0);
if parents == 0 {
depths[i] = 0;
queue.push_back(i);
}
}
// BFS to compute min depth
while let Some(idx) = queue.pop_front() {
let current_depth = depths[idx];
let node_id = &node_ids[idx];
// Find children: nodes that list this node as a parent
for (j, step) in steps.iter().enumerate() {
if depths[j] <= current_depth + 1 {
continue;
}
let is_child = step
.get("parent_node_ids")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|p| p.as_str() == Some(node_id.as_str())))
.unwrap_or(false);
if is_child {
depths[j] = current_depth + 1;
queue.push_back(j);
}
}
}
// Handle unreachable nodes
for d in depths.iter_mut() {
if *d == usize::MAX {
*d = 0;
}
}
// Cap depths at MAX_PHASES - 1 so deeper iterations merge into the last phase
for d in depths.iter_mut() {
if *d >= MAX_PHASES {
*d = MAX_PHASES - 1;
}
}
// Group by (capped) depth
let max_depth = depths.iter().copied().max().unwrap_or(0);
let mut phases: Vec<Vec<usize>> = Vec::new();
for d in 0..=max_depth {
let indices: Vec<usize> = depths
.iter()
.enumerate()
.filter(|(_, &dep)| dep == d)
.map(|(i, _)| i)
.collect();
if !indices.is_empty() {
phases.push(indices);
}
}
phases
}
/// Format BSON datetime to readable string
pub(crate) fn format_bson_time(val: &serde_json::Value) -> String {
// Handle BSON {"$date":{"$numberLong":"..."}}
if let Some(date_obj) = val.get("$date") {
if let Some(ms_str) = date_obj.get("$numberLong").and_then(|v| v.as_str()) {
if let Ok(ms) = ms_str.parse::<i64>() {
let secs = ms / 1000;
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
return format!("{h:02}:{m:02}:{s:02}");
}
}
// Handle {"$date": "2025-..."}
if let Some(s) = date_obj.as_str() {
return s.to_string();
}
}
// Handle plain string
if let Some(s) = val.as_str() {
return s.to_string();
}
String::new()
}
/// Compute duration string from started_at and completed_at
pub(crate) fn compute_duration(step: &serde_json::Value) -> String {
let extract_ms = |val: &serde_json::Value| -> Option<i64> {
val.get("$date")?
.get("$numberLong")?
.as_str()?
.parse::<i64>()
.ok()
};
let started = step.get("started_at").and_then(extract_ms);
let completed = step.get("completed_at").and_then(extract_ms);
match (started, completed) {
(Some(s), Some(c)) => {
let diff_ms = c - s;
if diff_ms < 1000 {
format!("{}ms", diff_ms)
} else {
format!("{:.1}s", diff_ms as f64 / 1000.0)
}
}
_ => String::new(),
}
}