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
926 lines
50 KiB
Rust
926 lines
50 KiB
Rust
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" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|