feat: pentest onboarding — streaming, browser automation, reports, user cleanup (#16)
All checks were successful
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
This commit was merged in pull request #16.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
925
compliance-dashboard/src/components/pentest_wizard.rs
Normal file
925
compliance-dashboard/src/components/pentest_wizard.rs
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user