use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; use dioxus_free_icons::Icon; use crate::app::Route; use crate::infrastructure::dast::fetch_dast_targets; use crate::infrastructure::pentest::{create_pentest_session_wizard, lookup_repo_by_url}; use crate::infrastructure::repositories::{fetch_repositories, fetch_ssh_public_key}; const DISCLAIMER_TEXT: &str = "I confirm that I have authorization to perform security testing \ against the specified target. I understand that penetration testing may cause disruption to the \ target application. I accept full responsibility for ensuring this test is conducted within \ legal boundaries and with proper authorization from the system owner."; /// Returns true if a git URL looks like an SSH URL (git@ or ssh://) fn is_ssh_url(url: &str) -> bool { let trimmed = url.trim(); trimmed.starts_with("git@") || trimmed.starts_with("ssh://") } #[component] pub fn PentestWizard(show: Signal) -> Element { let mut step = use_signal(|| 1u8); let mut creating = use_signal(|| false); // Step 1: Target & Scope let mut app_url = use_signal(String::new); let mut git_repo_url = use_signal(String::new); let mut branch = use_signal(String::new); let mut commit_hash = use_signal(String::new); let mut app_type = use_signal(|| "web_app".to_string()); let mut rate_limit = use_signal(|| "10".to_string()); // Repo lookup state let mut repo_looked_up = use_signal(|| false); let mut repo_name = use_signal(String::new); // Dropdown state: existing targets and repos let mut show_target_dropdown = use_signal(|| false); let mut show_repo_dropdown = use_signal(|| false); let existing_targets = use_resource(|| async { fetch_dast_targets().await.ok() }); let existing_repos = use_resource(|| async { fetch_repositories(1).await.ok() }); // SSH key state for private repos let mut ssh_public_key = use_signal(String::new); let mut ssh_key_loaded = use_signal(|| false); // Step 2: Authentication let mut requires_auth = use_signal(|| false); let mut auth_mode = use_signal(|| "manual".to_string()); // "manual" | "auto_register" let mut auth_username = use_signal(String::new); let mut auth_password = use_signal(String::new); let mut registration_url = use_signal(String::new); let mut verification_email = use_signal(String::new); let mut imap_host = use_signal(String::new); let mut imap_port = use_signal(|| "993".to_string()); let mut imap_username = use_signal(String::new); let mut imap_password = use_signal(String::new); let mut show_imap_settings = use_signal(|| false); let mut cleanup_test_user = use_signal(|| false); let mut custom_headers = use_signal(Vec::<(String, String)>::new); // Step 3: Strategy & Instructions let mut strategy = use_signal(|| "comprehensive".to_string()); let mut allow_destructive = use_signal(|| false); let mut initial_instructions = use_signal(String::new); let mut scope_exclusions = use_signal(String::new); let mut environment = use_signal(|| "development".to_string()); let mut max_duration = use_signal(|| "30".to_string()); let mut tester_name = use_signal(String::new); let mut tester_email = use_signal(String::new); // Step 4: Disclaimer let mut disclaimer_accepted = use_signal(|| false); let close = move |_| { show.set(false); step.set(1); }; let on_skip_to_blackbox = move |_| { // Jump to step 4 with skip mode step.set(4); }; let can_skip = !app_url.read().is_empty(); let on_submit = move |_| { creating.set(true); let url = app_url.read().clone(); let git = git_repo_url.read().clone(); let br = branch.read().clone(); let ch = commit_hash.read().clone(); let at = app_type.read().clone(); let rl = rate_limit.read().parse::().unwrap_or(10); let req_auth = *requires_auth.read(); let am = auth_mode.read().clone(); let au = auth_username.read().clone(); let ap = auth_password.read().clone(); let ru = registration_url.read().clone(); let ve = verification_email.read().clone(); let ih = imap_host.read().clone(); let ip = imap_port.read().parse::().unwrap_or(993); let iu = imap_username.read().clone(); let iw = imap_password.read().clone(); let cu = *cleanup_test_user.read(); let hdrs = custom_headers.read().clone(); let strat = strategy.read().clone(); let ad = *allow_destructive.read(); let ii = initial_instructions.read().clone(); let se = scope_exclusions.read().clone(); let env = environment.read().clone(); let md = max_duration.read().parse::().unwrap_or(30); let tn = tester_name.read().clone(); let te = tester_email.read().clone(); let skip = *step.read() == 4 && !req_auth; // simplified skip check let mut show = show; spawn(async move { let headers_map: std::collections::HashMap = hdrs .into_iter() .filter(|(k, v)| !k.is_empty() && !v.is_empty()) .collect(); let scope_excl: Vec = se .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect(); let config = serde_json::json!({ "app_url": url, "git_repo_url": if git.is_empty() { None } else { Some(git) }, "branch": if br.is_empty() { None } else { Some(br) }, "commit_hash": if ch.is_empty() { None } else { Some(ch) }, "app_type": if at.is_empty() { None } else { Some(at) }, "rate_limit": rl, "auth": { "mode": if !req_auth { "none" } else { &am }, "username": if au.is_empty() { None } else { Some(&au) }, "password": if ap.is_empty() { None } else { Some(&ap) }, "registration_url": if ru.is_empty() { None } else { Some(&ru) }, "verification_email": if ve.is_empty() { None } else { Some(&ve) }, "imap_host": if ih.is_empty() { None } else { Some(&ih) }, "imap_port": ip, "imap_username": if iu.is_empty() { None } else { Some(&iu) }, "imap_password": if iw.is_empty() { None } else { Some(&iw) }, "cleanup_test_user": cu, }, "custom_headers": headers_map, "strategy": strat, "allow_destructive": ad, "initial_instructions": if ii.is_empty() { None } else { Some(&ii) }, "scope_exclusions": scope_excl, "disclaimer_accepted": true, "disclaimer_accepted_at": chrono::Utc::now().to_rfc3339(), "environment": env, "tester": { "name": tn, "email": te }, "max_duration_minutes": md, "skip_mode": skip, }); match create_pentest_session_wizard(config.to_string()).await { Ok(resp) => { let session_id = resp .data .get("_id") .and_then(|v| v.get("$oid")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); creating.set(false); show.set(false); if !session_id.is_empty() { navigator().push(Route::PentestSessionPage { session_id: session_id.clone(), }); } } Err(_) => { creating.set(false); } } }); }; // Build filtered target list for dropdown let target_options: Vec<(String, String)> = { let t = existing_targets.read(); match &*t { Some(Some(data)) => data .data .iter() .filter_map(|t| { let url = t.get("base_url").and_then(|v| v.as_str())?.to_string(); let name = t .get("name") .and_then(|v| v.as_str()) .unwrap_or(&url) .to_string(); Some((url, name)) }) .collect(), _ => Vec::new(), } }; // Build filtered repo list for dropdown let repo_options: Vec<(String, String)> = { let r = existing_repos.read(); match &*r { Some(Some(data)) => data .data .iter() .map(|r| (r.git_url.clone(), r.name.clone())) .collect(), _ => Vec::new(), } }; // Filter targets based on current input let app_url_val = app_url.read().clone(); let filtered_targets: Vec<(String, String)> = if app_url_val.is_empty() { target_options.clone() } else { let lower = app_url_val.to_lowercase(); target_options .iter() .filter(|(url, name)| { url.to_lowercase().contains(&lower) || name.to_lowercase().contains(&lower) }) .cloned() .collect() }; // Filter repos based on current input let git_url_val = git_repo_url.read().clone(); let filtered_repos: Vec<(String, String)> = if git_url_val.is_empty() { repo_options.clone() } else { let lower = git_url_val.to_lowercase(); repo_options .iter() .filter(|(url, name)| { url.to_lowercase().contains(&lower) || name.to_lowercase().contains(&lower) }) .cloned() .collect() }; let current_step = *step.read(); let show_ssh_section = is_ssh_url(&git_repo_url.read()); rsx! { div { class: "wizard-backdrop", onclick: close, div { class: "wizard-dialog", onclick: move |e| e.stop_propagation(), // Close button (always visible) button { class: "wizard-close-btn", onclick: close, Icon { icon: BsXLg, width: 16, height: 16 } } // Step indicator div { class: "wizard-steps", for (i, label) in [(1, "Target"), (2, "Auth"), (3, "Strategy"), (4, "Confirm")].iter() { { let step_class = if current_step == *i { "wizard-step active" } else if current_step > *i { "wizard-step completed" } else { "wizard-step" }; rsx! { div { class: "{step_class}", div { class: "wizard-step-dot", "{i}" } span { class: "wizard-step-label", "{label}" } } } } } } // Body div { class: "wizard-body", match current_step { 1 => rsx! { h3 { style: "margin: 0 0 16px 0;", "Target & Scope" } // App URL with dropdown div { class: "wizard-field", style: "position: relative;", label { "App URL " span { style: "color: #dc2626;", "*" } } input { class: "chat-input", r#type: "url", placeholder: "https://example.com", value: "{app_url}", oninput: move |e| { app_url.set(e.value()); show_target_dropdown.set(true); }, onfocus: move |_| show_target_dropdown.set(true), } // Dropdown of existing targets if *show_target_dropdown.read() && !filtered_targets.is_empty() { div { class: "wizard-dropdown", for (url, name) in filtered_targets.iter() { { let url_clone = url.clone(); let display_name = name.clone(); let display_url = url.clone(); rsx! { div { class: "wizard-dropdown-item", onclick: move |_| { app_url.set(url_clone.clone()); show_target_dropdown.set(false); }, div { style: "font-weight: 500;", "{display_name}" } div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace;", "{display_url}" } } } } } } } } // Git Repo URL with dropdown div { class: "wizard-field", style: "position: relative;", label { "Git Repository URL" } div { style: "display: flex; gap: 8px;", div { style: "flex: 1; position: relative;", input { class: "chat-input", style: "width: 100%;", placeholder: "https://github.com/org/repo.git", value: "{git_repo_url}", oninput: move |e| { git_repo_url.set(e.value()); repo_looked_up.set(false); show_repo_dropdown.set(true); // Fetch SSH key if it looks like an SSH URL if is_ssh_url(&e.value()) && !*ssh_key_loaded.read() { spawn(async move { match fetch_ssh_public_key().await { Ok(key) => { ssh_public_key.set(key); ssh_key_loaded.set(true); } Err(_) => { ssh_public_key.set("(not available)".to_string()); ssh_key_loaded.set(true); } } }); } }, onfocus: move |_| show_repo_dropdown.set(true), } // Dropdown of existing repos if *show_repo_dropdown.read() && !filtered_repos.is_empty() { div { class: "wizard-dropdown", for (url, name) in filtered_repos.iter() { { let url_clone = url.clone(); let display_name = name.clone(); let display_url = url.clone(); let is_ssh = is_ssh_url(&url_clone); rsx! { div { class: "wizard-dropdown-item", onclick: move |_| { git_repo_url.set(url_clone.clone()); show_repo_dropdown.set(false); repo_looked_up.set(false); // Auto-fetch SSH key if SSH URL selected if is_ssh && !*ssh_key_loaded.read() { spawn(async move { match fetch_ssh_public_key().await { Ok(key) => { ssh_public_key.set(key); ssh_key_loaded.set(true); } Err(_) => { ssh_public_key.set("(not available)".to_string()); ssh_key_loaded.set(true); } } }); } }, div { style: "font-weight: 500;", "{display_name}" } div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace;", "{display_url}" } } } } } } } } button { class: "btn btn-ghost btn-sm", disabled: git_repo_url.read().is_empty(), onclick: move |_| { let url = git_repo_url.read().clone(); spawn(async move { if let Ok(resp) = lookup_repo_by_url(url).await { if let Some(name) = resp.get("name").and_then(|v| v.as_str()) { repo_name.set(name.to_string()); if let Some(b) = resp.get("default_branch").and_then(|v| v.as_str()) { branch.set(b.to_string()); } if let Some(c) = resp.get("last_scanned_commit").and_then(|v| v.as_str()) { commit_hash.set(c.to_string()); } } repo_looked_up.set(true); } }); }, "Lookup" } } if *repo_looked_up.read() && !repo_name.read().is_empty() { div { style: "font-size: 0.8rem; color: var(--accent); margin-top: 4px;", Icon { icon: BsCheckCircle, width: 12, height: 12 } " Found: {repo_name}" } } } // SSH deploy key section (shown for SSH URLs) if show_ssh_section { div { class: "wizard-ssh-key", div { style: "display: flex; align-items: center; gap: 6px; margin-bottom: 6px;", Icon { icon: BsKeyFill, width: 14, height: 14 } span { style: "font-size: 0.8rem; font-weight: 600;", "SSH Deploy Key" } } p { style: "font-size: 0.75rem; color: var(--text-secondary); margin: 0 0 6px 0;", "Add this read-only deploy key to your repository settings:" } div { class: "wizard-ssh-key-box", if ssh_public_key.read().is_empty() { "Loading..." } else { "{ssh_public_key}" } } } } div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;", div { class: "wizard-field", label { "Branch" } input { class: "chat-input", placeholder: "main", value: "{branch}", oninput: move |e| branch.set(e.value()), } } div { class: "wizard-field", label { "Commit" } input { class: "chat-input", placeholder: "HEAD", value: "{commit_hash}", oninput: move |e| commit_hash.set(e.value()), } } } div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;", div { class: "wizard-field", label { "App Type" } select { class: "chat-input", value: "{app_type}", onchange: move |e| app_type.set(e.value()), option { value: "web_app", "Web Application" } option { value: "api", "API" } option { value: "spa", "Single-Page App" } option { value: "mobile_backend", "Mobile Backend" } } } div { class: "wizard-field", label { "Rate Limit (req/s)" } input { class: "chat-input", r#type: "number", value: "{rate_limit}", oninput: move |e| rate_limit.set(e.value()), } } } }, 2 => rsx! { h3 { style: "margin: 0 0 16px 0;", "Authentication" } div { class: "wizard-field", label { style: "display: flex; align-items: center; gap: 8px;", "Requires authentication?" div { class: if *requires_auth.read() { "wizard-toggle active" } else { "wizard-toggle" }, onclick: move |_| { let v = *requires_auth.read(); requires_auth.set(!v); }, div { class: "wizard-toggle-knob" } } } } if *requires_auth.read() { div { class: "wizard-field", div { style: "display: flex; gap: 12px; margin-bottom: 12px;", label { style: "display: flex; align-items: center; gap: 4px; cursor: pointer;", input { r#type: "radio", name: "auth_mode", value: "manual", checked: auth_mode.read().as_str() == "manual", onchange: move |_| auth_mode.set("manual".to_string()), } "Manual Credentials" } label { style: "display: flex; align-items: center; gap: 4px; cursor: pointer;", input { r#type: "radio", name: "auth_mode", value: "auto_register", checked: auth_mode.read().as_str() == "auto_register", onchange: move |_| auth_mode.set("auto_register".to_string()), } "Auto-Register" } } } if auth_mode.read().as_str() == "manual" { div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;", div { class: "wizard-field", label { "Username" } input { class: "chat-input", value: "{auth_username}", oninput: move |e| auth_username.set(e.value()), } } div { class: "wizard-field", label { "Password" } input { class: "chat-input", r#type: "password", value: "{auth_password}", oninput: move |e| auth_password.set(e.value()), } } } } if auth_mode.read().as_str() == "auto_register" { div { class: "wizard-field", label { "Registration URL" span { style: "font-weight: 400; color: var(--text-tertiary); font-size: 0.75rem; margin-left: 6px;", "(optional)" } } input { class: "chat-input", placeholder: "https://example.com/register", value: "{registration_url}", oninput: move |e| registration_url.set(e.value()), } div { style: "font-size: 0.75rem; color: var(--text-tertiary); margin-top: 3px;", "If omitted, the orchestrator will use Playwright to discover the registration page automatically." } } // Verification email (plus-addressing) — optional override div { class: "wizard-field", label { "Verification Email" span { style: "font-weight: 400; color: var(--text-tertiary); font-size: 0.75rem; margin-left: 6px;", "(optional override)" } } input { class: "chat-input", placeholder: "pentest@scanner.example.com", value: "{verification_email}", oninput: move |e| verification_email.set(e.value()), } div { style: "font-size: 0.75rem; color: var(--text-tertiary); margin-top: 3px;", "Overrides the agent's default mailbox. Uses plus-addressing: " code { style: "font-size: 0.7rem;", "base+sessionid@domain" } ". Leave blank to use the server default." } } // IMAP settings (collapsible) div { class: "wizard-field", button { class: "btn btn-ghost btn-sm", style: "font-size: 0.8rem; padding: 2px 8px;", onclick: move |_| { let v = *show_imap_settings.read(); show_imap_settings.set(!v); }, if *show_imap_settings.read() { Icon { icon: BsChevronDown, width: 10, height: 10 } } else { Icon { icon: BsChevronRight, width: 10, height: 10 } } " IMAP Settings" } } if *show_imap_settings.read() { div { style: "display: grid; grid-template-columns: 2fr 1fr; gap: 12px;", div { class: "wizard-field", label { "IMAP Host" } input { class: "chat-input", placeholder: "imap.example.com", value: "{imap_host}", oninput: move |e| imap_host.set(e.value()), } } div { class: "wizard-field", label { "Port" } input { class: "chat-input", r#type: "number", value: "{imap_port}", oninput: move |e| imap_port.set(e.value()), } } } div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;", div { class: "wizard-field", label { "IMAP Username" span { style: "font-weight: 400; color: var(--text-tertiary); font-size: 0.75rem; margin-left: 6px;", "(defaults to email)" } } input { class: "chat-input", placeholder: "pentest@scanner.example.com", value: "{imap_username}", oninput: move |e| imap_username.set(e.value()), } } div { class: "wizard-field", label { "IMAP Password" } input { class: "chat-input", r#type: "password", placeholder: "App password", value: "{imap_password}", oninput: move |e| imap_password.set(e.value()), } } } } // Cleanup option div { style: "margin-top: 8px;", label { style: "display: flex; align-items: center; gap: 6px; font-size: 0.85rem; cursor: pointer;", input { r#type: "checkbox", checked: *cleanup_test_user.read(), onchange: move |_| { let v = *cleanup_test_user.read(); cleanup_test_user.set(!v); }, } "Cleanup test user after" } } } } // Custom headers div { class: "wizard-field", style: "margin-top: 16px;", label { "Custom HTTP Headers" } for (idx, _) in custom_headers.read().iter().enumerate() { { let key = custom_headers.read().get(idx).map(|(k, _)| k.clone()).unwrap_or_default(); let val = custom_headers.read().get(idx).map(|(_, v)| v.clone()).unwrap_or_default(); rsx! { div { style: "display: flex; gap: 8px; margin-bottom: 4px;", input { class: "chat-input", style: "flex: 1;", placeholder: "Header name", value: "{key}", oninput: move |e| { let mut h = custom_headers.write(); if let Some(pair) = h.get_mut(idx) { pair.0 = e.value(); } }, } input { class: "chat-input", style: "flex: 1;", placeholder: "Value", value: "{val}", oninput: move |e| { let mut h = custom_headers.write(); if let Some(pair) = h.get_mut(idx) { pair.1 = e.value(); } }, } button { class: "btn btn-ghost btn-sm", style: "color: #dc2626;", onclick: move |_| { custom_headers.write().remove(idx); }, Icon { icon: BsXCircle, width: 14, height: 14 } } } } } } button { class: "btn btn-ghost btn-sm", onclick: move |_| { custom_headers.write().push((String::new(), String::new())); }, Icon { icon: BsPlusCircle, width: 12, height: 12 } " Add Header" } } }, 3 => rsx! { h3 { style: "margin: 0 0 16px 0;", "Strategy & Instructions" } div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;", div { class: "wizard-field", label { "Strategy" } select { class: "chat-input", value: "{strategy}", onchange: move |e| strategy.set(e.value()), option { value: "comprehensive", "Comprehensive" } option { value: "quick", "Quick Scan" } option { value: "targeted", "Targeted (SAST-guided)" } option { value: "aggressive", "Aggressive" } option { value: "stealth", "Stealth" } } } div { class: "wizard-field", label { "Environment" } select { class: "chat-input", value: "{environment}", onchange: move |e| environment.set(e.value()), option { value: "development", "Development" } option { value: "staging", "Staging" } option { value: "production", "Production" } } } } div { class: "wizard-field", label { style: "display: flex; align-items: center; gap: 8px;", input { r#type: "checkbox", checked: *allow_destructive.read(), onchange: move |_| { let v = *allow_destructive.read(); allow_destructive.set(!v); }, } "Allow destructive tests (DELETE, PUT, data modification)" } } div { class: "wizard-field", label { "Initial Instructions" } textarea { class: "chat-input", style: "width: 100%; min-height: 80px;", placeholder: "Describe focus areas, known issues, or specific test scenarios...", value: "{initial_instructions}", oninput: move |e| initial_instructions.set(e.value()), } } div { class: "wizard-field", label { "Scope Exclusions (one path per line)" } textarea { class: "chat-input", style: "width: 100%; min-height: 60px;", placeholder: "/admin\n/health\n/api/v1/internal", value: "{scope_exclusions}", oninput: move |e| scope_exclusions.set(e.value()), } } div { style: "display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;", div { class: "wizard-field", label { "Max Duration (min)" } input { class: "chat-input", r#type: "number", value: "{max_duration}", oninput: move |e| max_duration.set(e.value()), } } div { class: "wizard-field", label { "Tester Name" } input { class: "chat-input", value: "{tester_name}", oninput: move |e| tester_name.set(e.value()), } } div { class: "wizard-field", label { "Tester Email" } input { class: "chat-input", r#type: "email", value: "{tester_email}", oninput: move |e| tester_email.set(e.value()), } } } }, 4 => rsx! { h3 { style: "margin: 0 0 16px 0;", "Review & Confirm" } // Summary div { class: "wizard-summary", dl { dt { "Target URL" } dd { code { "{app_url}" } } if !git_repo_url.read().is_empty() { dt { "Git Repository" } dd { "{git_repo_url}" } } dt { "Strategy" } dd { "{strategy}" } dt { "Environment" } dd { "{environment}" } dt { "Auth Mode" } dd { if *requires_auth.read() { "{auth_mode}" } else { "None" } } dt { "Max Duration" } dd { "{max_duration} minutes" } if *allow_destructive.read() { dt { "Destructive Tests" } dd { "Allowed" } } if !tester_name.read().is_empty() { dt { "Tester" } dd { "{tester_name} ({tester_email})" } } } } // Disclaimer div { class: "wizard-disclaimer", Icon { icon: BsExclamationTriangle, width: 16, height: 16 } p { style: "margin: 8px 0;", "{DISCLAIMER_TEXT}" } label { style: "display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600;", input { r#type: "checkbox", checked: *disclaimer_accepted.read(), onchange: move |_| { let v = *disclaimer_accepted.read(); disclaimer_accepted.set(!v); }, } "I accept this disclaimer" } } }, _ => rsx! {}, } } // Footer div { class: "wizard-footer", // Left side: skip button div { if current_step == 1 && can_skip { button { class: "btn btn-ghost btn-sm", onclick: on_skip_to_blackbox, Icon { icon: BsLightning, width: 12, height: 12 } " Skip to Black Box" } } } // Right side: navigation div { style: "display: flex; gap: 8px;", if current_step == 1 { button { class: "btn btn-ghost", onclick: close, "Cancel" } } else { button { class: "btn btn-ghost", onclick: move |_| step.set(current_step - 1), "Back" } } if current_step < 4 { button { class: "btn btn-primary", disabled: current_step == 1 && app_url.read().is_empty(), onclick: move |_| step.set(current_step + 1), "Next" } } if current_step == 4 { button { class: "btn btn-primary", disabled: !*disclaimer_accepted.read() || *creating.read(), onclick: on_submit, if *creating.read() { "Starting..." } else { "Start Pentest" } } } } } } } } }