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" }
}
}
}
}
}
}
}
}

View File

@@ -206,6 +206,65 @@ pub async fn create_pentest_session(
Ok(body)
}
/// Create a pentest session using the wizard configuration
#[server]
pub async fn create_pentest_session_wizard(
config_json: String,
) -> Result<PentestSessionResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/pentest/sessions", state.agent_api_url);
let config: serde_json::Value =
serde_json::from_str(&config_json).map_err(|e| ServerFnError::new(e.to_string()))?;
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({ "config": config }))
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!(
"Failed to create session: {text}"
)));
}
let body: PentestSessionResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
/// Look up a tracked repository by its git URL
#[server]
pub async fn lookup_repo_by_url(url: String) -> Result<serde_json::Value, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let encoded_url: String = url
.bytes()
.flat_map(|b| {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~' {
vec![b as char]
} else {
format!("%{:02X}", b).chars().collect()
}
})
.collect();
let api_url = format!(
"{}/api/v1/pentest/lookup-repo?url={}",
state.agent_api_url, encoded_url
);
let resp = reqwest::get(&api_url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body.get("data").cloned().unwrap_or(serde_json::Value::Null))
}
#[server]
pub async fn send_pentest_message(
session_id: String,
@@ -250,6 +309,48 @@ pub async fn stop_pentest_session(session_id: String) -> Result<(), ServerFnErro
Ok(())
}
#[server]
pub async fn pause_pentest_session(session_id: String) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/pentest/sessions/{session_id}/pause",
state.agent_api_url
);
let client = reqwest::Client::new();
let resp = client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!("Pause failed: {text}")));
}
Ok(())
}
#[server]
pub async fn resume_pentest_session(session_id: String) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/pentest/sessions/{session_id}/resume",
state.agent_api_url
);
let client = reqwest::Client::new();
let resp = client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!("Resume failed: {text}")));
}
Ok(())
}
#[server]
pub async fn fetch_pentest_findings(
session_id: String,

View File

@@ -4,59 +4,18 @@ use dioxus_free_icons::Icon;
use crate::app::Route;
use crate::components::page_header::PageHeader;
use crate::infrastructure::dast::fetch_dast_targets;
use crate::components::pentest_wizard::PentestWizard;
use crate::infrastructure::pentest::{
create_pentest_session, fetch_pentest_sessions, fetch_pentest_stats, stop_pentest_session,
fetch_pentest_sessions, fetch_pentest_stats, pause_pentest_session, resume_pentest_session,
stop_pentest_session,
};
#[component]
pub fn PentestDashboardPage() -> Element {
let mut sessions = use_resource(|| async { fetch_pentest_sessions().await.ok() });
let stats = use_resource(|| async { fetch_pentest_stats().await.ok() });
let targets = use_resource(|| async { fetch_dast_targets().await.ok() });
let mut show_modal = use_signal(|| false);
let mut new_target_id = use_signal(String::new);
let mut new_strategy = use_signal(|| "comprehensive".to_string());
let mut new_message = use_signal(String::new);
let mut creating = use_signal(|| false);
let on_create = move |_| {
let tid = new_target_id.read().clone();
let strat = new_strategy.read().clone();
let msg = new_message.read().clone();
if tid.is_empty() || msg.is_empty() {
return;
}
creating.set(true);
spawn(async move {
match create_pentest_session(tid, strat, msg).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_modal.set(false);
new_target_id.set(String::new());
new_message.set(String::new());
if !session_id.is_empty() {
navigator().push(Route::PentestSessionPage {
session_id: session_id.clone(),
});
} else {
sessions.restart();
}
}
Err(_) => {
creating.set(false);
}
}
});
};
let mut show_wizard = use_signal(|| false);
// Extract stats values
let running_sessions = {
@@ -193,7 +152,7 @@ pub fn PentestDashboardPage() -> Element {
div { style: "display: flex; gap: 12px; margin-bottom: 24px;",
button {
class: "btn btn-primary",
onclick: move |_| show_modal.set(true),
onclick: move |_| show_wizard.set(true),
Icon { icon: BsPlusCircle, width: 14, height: 14 }
" New Pentest"
}
@@ -235,7 +194,10 @@ pub fn PentestDashboardPage() -> Element {
};
{
let is_session_running = status == "running";
let is_session_paused = status == "paused";
let stop_id = id.clone();
let pause_id = id.clone();
let resume_id = id.clone();
rsx! {
div { class: "card", style: "padding: 16px; transition: border-color 0.15s;",
Link {
@@ -272,8 +234,42 @@ pub fn PentestDashboardPage() -> Element {
}
}
}
if is_session_running {
div { style: "margin-top: 8px; display: flex; justify-content: flex-end;",
if is_session_running || is_session_paused {
div { style: "margin-top: 8px; display: flex; justify-content: flex-end; gap: 6px;",
if is_session_running {
button {
class: "btn btn-ghost",
style: "font-size: 0.8rem; padding: 4px 12px; color: #d97706; border-color: #d97706;",
onclick: move |e| {
e.stop_propagation();
e.prevent_default();
let sid = pause_id.clone();
spawn(async move {
let _ = pause_pentest_session(sid).await;
sessions.restart();
});
},
Icon { icon: BsPauseCircle, width: 12, height: 12 }
" Pause"
}
}
if is_session_paused {
button {
class: "btn btn-ghost",
style: "font-size: 0.8rem; padding: 4px 12px; color: #16a34a; border-color: #16a34a;",
onclick: move |e| {
e.stop_propagation();
e.prevent_default();
let sid = resume_id.clone();
spawn(async move {
let _ = resume_pentest_session(sid).await;
sessions.restart();
});
},
Icon { icon: BsPlayCircle, width: 12, height: 12 }
" Resume"
}
}
button {
class: "btn btn-ghost",
style: "font-size: 0.8rem; padding: 4px 12px; color: #dc2626; border-color: #dc2626;",
@@ -305,97 +301,9 @@ pub fn PentestDashboardPage() -> Element {
}
}
// New Pentest Modal
if *show_modal.read() {
div {
style: "position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000;",
onclick: move |_| show_modal.set(false),
div {
style: "background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 24px; width: 480px; max-width: 90vw;",
onclick: move |e| e.stop_propagation(),
h3 { style: "margin: 0 0 16px 0;", "New Pentest Session" }
// Target selection
div { style: "margin-bottom: 12px;",
label { style: "display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px;",
"Target"
}
select {
class: "chat-input",
style: "width: 100%; padding: 8px; resize: none; height: auto;",
value: "{new_target_id}",
onchange: move |e| new_target_id.set(e.value()),
option { value: "", "Select a target..." }
match &*targets.read() {
Some(Some(data)) => {
rsx! {
for target in &data.data {
{
let tid = target.get("_id")
.and_then(|v| v.get("$oid"))
.and_then(|v| v.as_str())
.unwrap_or("").to_string();
let tname = target.get("name").and_then(|v| v.as_str()).unwrap_or("Unknown").to_string();
let turl = target.get("base_url").and_then(|v| v.as_str()).unwrap_or("").to_string();
rsx! {
option { value: "{tid}", "{tname} ({turl})" }
}
}
}
}
},
_ => rsx! {},
}
}
}
// Strategy selection
div { style: "margin-bottom: 12px;",
label { style: "display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px;",
"Strategy"
}
select {
class: "chat-input",
style: "width: 100%; padding: 8px; resize: none; height: auto;",
value: "{new_strategy}",
onchange: move |e| new_strategy.set(e.value()),
option { value: "comprehensive", "Comprehensive" }
option { value: "quick", "Quick Scan" }
option { value: "owasp_top_10", "OWASP Top 10" }
option { value: "api_focused", "API Focused" }
option { value: "authentication", "Authentication" }
}
}
// Initial message
div { style: "margin-bottom: 16px;",
label { style: "display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px;",
"Initial Instructions"
}
textarea {
class: "chat-input",
style: "width: 100%; min-height: 80px;",
placeholder: "Describe the scope and goals of this pentest...",
value: "{new_message}",
oninput: move |e| new_message.set(e.value()),
}
}
div { style: "display: flex; justify-content: flex-end; gap: 8px;",
button {
class: "btn btn-ghost",
onclick: move |_| show_modal.set(false),
"Cancel"
}
button {
class: "btn btn-primary",
disabled: *creating.read() || new_target_id.read().is_empty() || new_message.read().is_empty(),
onclick: on_create,
if *creating.read() { "Creating..." } else { "Start Pentest" }
}
}
}
}
// Pentest Wizard
if *show_wizard.read() {
PentestWizard { show: show_wizard }
}
}
}

View File

@@ -7,6 +7,7 @@ use crate::components::attack_chain::AttackChainView;
use crate::components::severity_badge::SeverityBadge;
use crate::infrastructure::pentest::{
export_pentest_report, fetch_attack_chain, fetch_pentest_findings, fetch_pentest_session,
pause_pentest_session, resume_pentest_session,
};
#[component]
@@ -87,11 +88,13 @@ pub fn PentestSessionPage(session_id: String) -> Element {
};
let is_running = session_status == "running";
let is_paused = session_status == "paused";
let is_active = is_running || is_paused;
// Poll while running
// Poll while running or paused
use_effect(move || {
let _gen = *poll_gen.read();
if is_running {
if is_active {
spawn(async move {
#[cfg(feature = "web")]
gloo_timers::future::TimeoutFuture::new(3_000).await;
@@ -226,9 +229,55 @@ pub fn PentestSessionPage(session_id: String) -> Element {
" Running..."
}
}
if is_paused {
span { style: "font-size: 0.8rem; color: #d97706;",
Icon { icon: BsPauseCircle, width: 12, height: 12 }
" Paused"
}
}
}
}
div { style: "display: flex; gap: 8px;",
if is_running {
{
let sid_pause = session_id.clone();
rsx! {
button {
class: "btn btn-ghost",
style: "font-size: 0.85rem; color: #d97706; border-color: #d97706;",
onclick: move |_| {
let sid = sid_pause.clone();
spawn(async move {
let _ = pause_pentest_session(sid).await;
session.restart();
});
},
Icon { icon: BsPauseCircle, width: 14, height: 14 }
" Pause"
}
}
}
}
if is_paused {
{
let sid_resume = session_id.clone();
rsx! {
button {
class: "btn btn-ghost",
style: "font-size: 0.85rem; color: #16a34a; border-color: #16a34a;",
onclick: move |_| {
let sid = sid_resume.clone();
spawn(async move {
let _ = resume_pentest_session(sid).await;
session.restart();
});
},
Icon { icon: BsPlayCircle, width: 14, height: 14 }
" Resume"
}
}
}
}
button {
class: "btn btn-primary",
style: "font-size: 0.85rem;",