feat: pentest onboarding — streaming, browser automation, reports, user cleanup (#16)
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

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
This commit was merged in pull request #16.
This commit is contained in:
2026-03-17 20:32:20 +00:00
parent 11e1c5f438
commit c461faa2fb
57 changed files with 8844 additions and 2423 deletions

View File

@@ -118,9 +118,12 @@ pub(crate) fn cat_label(cat: &str) -> &'static str {
}
}
/// Phase name heuristic based on depth
pub(crate) fn phase_name(depth: usize) -> &'static str {
match depth {
/// 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",
@@ -133,8 +136,8 @@ pub(crate) fn phase_name(depth: usize) -> &'static str {
}
/// Short label for phase rail
pub(crate) fn phase_short_name(depth: usize) -> &'static str {
match depth {
pub(crate) fn phase_short_name(phase_idx: usize) -> &'static str {
match phase_idx {
0 => "Recon",
1 => "Analysis",
2 => "Boundary",
@@ -214,7 +217,14 @@ pub(crate) fn compute_phases(steps: &[serde_json::Value]) -> Vec<Vec<usize>> {
}
}
// Group by depth
// 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 {

View File

@@ -270,8 +270,17 @@ pub fn AttackChainView(
let duration = compute_duration(step);
let started = step.get("started_at").map(format_bson_time).unwrap_or_default();
let tool_input_json = step.get("tool_input")
.map(|v| serde_json::to_string_pretty(v).unwrap_or_default())
.unwrap_or_default();
let tool_output_json = step.get("tool_output")
.map(|v| serde_json::to_string_pretty(v).unwrap_or_default())
.unwrap_or_default();
let is_pending = status == "pending";
let is_node_running = status == "running";
let pending_cls = if is_pending { " is-pending" } else { "" };
let running_cls = if is_node_running { " ac-node-running" } else { "" };
let duration_cls = if status == "running" { "ac-tool-duration running-text" } else { "ac-tool-duration" };
let duration_text = if status == "running" {
@@ -299,7 +308,7 @@ pub fn AttackChainView(
rsx! {
div {
class: "ac-tool-row{pending_cls}",
class: "ac-tool-row{pending_cls}{running_cls}",
id: "{row_id}",
onclick: move |_| {
if is_pending { return; }
@@ -321,30 +330,40 @@ pub fn AttackChainView(
div {
class: "ac-tool-detail",
id: "{detail_id_clone}",
if !reasoning.is_empty() || !started.is_empty() {
div { class: "ac-tool-detail-inner",
if !reasoning.is_empty() {
div { class: "ac-reasoning-block", "{reasoning}" }
}
if !started.is_empty() {
div { class: "ac-detail-grid",
span { class: "ac-detail-label", "Started" }
span { class: "ac-detail-value", "{started}" }
if !duration_text.is_empty() && status != "running" && duration_text != "\u{2014}" {
span { class: "ac-detail-label", "Duration" }
span { class: "ac-detail-value", "{duration_text}" }
}
span { class: "ac-detail-label", "Status" }
if status == "completed" {
span { class: "ac-detail-value", style: "color: var(--success, #16a34a);", "Completed" }
} else if status == "failed" {
span { class: "ac-detail-value", style: "color: var(--danger, #dc2626);", "Failed" }
} else if status == "running" {
span { class: "ac-detail-value", style: "color: var(--warning, #d97706);", "Running" }
} else {
span { class: "ac-detail-value", "{status}" }
}
div { class: "ac-tool-detail-inner",
if !reasoning.is_empty() {
div { class: "ac-reasoning-block", "{reasoning}" }
}
if !started.is_empty() {
div { class: "ac-detail-grid",
span { class: "ac-detail-label", "Started" }
span { class: "ac-detail-value", "{started}" }
if !duration_text.is_empty() && status != "running" && duration_text != "\u{2014}" {
span { class: "ac-detail-label", "Duration" }
span { class: "ac-detail-value", "{duration_text}" }
}
span { class: "ac-detail-label", "Status" }
if status == "completed" {
span { class: "ac-detail-value", style: "color: var(--success, #16a34a);", "Completed" }
} else if status == "failed" {
span { class: "ac-detail-value", style: "color: var(--danger, #dc2626);", "Failed" }
} else if status == "running" {
span { class: "ac-detail-value", style: "color: var(--warning, #d97706);", "Running" }
} else {
span { class: "ac-detail-value", "{status}" }
}
}
}
if !tool_input_json.is_empty() && tool_input_json != "null" {
div { class: "ac-data-section",
div { class: "ac-data-label", "Input" }
pre { class: "ac-data-block", "{tool_input_json}" }
}
}
if !tool_output_json.is_empty() && tool_output_json != "null" {
div { class: "ac-data-section",
div { class: "ac-data-label", "Output" }
pre { class: "ac-data-block", "{tool_output_json}" }
}
}
}

View File

@@ -5,6 +5,7 @@ pub mod code_snippet;
pub mod file_tree;
pub mod page_header;
pub mod pagination;
pub mod pentest_wizard;
pub mod severity_badge;
pub mod sidebar;
pub mod stat_card;

View File

@@ -0,0 +1,925 @@
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<bool>) -> 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::<u32>().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::<u16>().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::<u32>().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<String, String> = hdrs
.into_iter()
.filter(|(k, v)| !k.is_empty() && !v.is_empty())
.collect();
let scope_excl: Vec<String> = 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" }
}
}
}
}
}
}
}
}