Skip to main content

ploit_malper/exploit/
cli.rs

1use std::io::{self, Write};
2use std::time::Duration;
3
4use crate::config::ConfigManager;
5use crate::db::models::ExploitExecution;
6use crate::error::{AppError, Result};
7use crate::exploit::models::*;
8use crate::exploit::msf::MsfFramework;
9use crate::exploit::planner::Planner;
10use crate::exploit::{execute_step, ExploitFramework};
11use crate::frontend::{open_backend, Args};
12
13const DEFAULT_JOB_TIMEOUT_S: u64 = 300;
14
15pub fn usage() {
16    println!(
17        "Usage: ploit-malper exploit <run_id> [--dry-run] [--yes] [--module <name>] \
18         [--target <host>] [--payload <name>] [--workspace <name>] [--job-timeout <secs>] \
19         [--backend pocketbase|sqlite] [--pocketbase-url URL] [--sqlite-path PATH] [--verbose]"
20    );
21}
22
23pub fn cmd_exploit(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
24    let parsed = Args::parse(args)?;
25    if parsed.has("help") {
26        usage();
27        return Ok(());
28    }
29
30    let run_ref = parsed.positionals().first().cloned().ok_or_else(|| {
31        AppError::Message(
32            "exploit requires a run id (e.g. ploit-malper exploit <run_id>)".to_string(),
33        )
34    })?;
35
36    let opts = ExploitOptions {
37        dry_run: parsed.has("dry-run"),
38        yes: parsed.has("yes"),
39        module_filter: parsed.get("module").map(str::to_string),
40        target_filter: parsed.get("target").map(str::to_string),
41        payload_override: parsed.get("payload").map(str::to_string),
42        workspace: parsed.get("workspace").map(str::to_string),
43        backend: parsed.get("backend").map(str::to_string),
44        pocketbase_url: parsed.get("pocketbase-url").map(str::to_string),
45        sqlite_path: parsed.get("sqlite-path").map(str::to_string),
46        verbose: parsed.has("verbose"),
47    };
48    let job_timeout = parsed
49        .get("job-timeout")
50        .map(|v| v.parse::<u64>())
51        .transpose()
52        .map_err(|_| AppError::Message("--job-timeout must be a number of seconds".to_string()))?
53        .unwrap_or(DEFAULT_JOB_TIMEOUT_S);
54
55    let mut storage = open_backend(config_mgr, &parsed)?;
56    let run = Planner::resolve_run(storage.as_mut(), &run_ref)?;
57
58    println!(
59        "[+] Run: {} (target={})",
60        short(&run.stable_id, 16),
61        run.target
62    );
63
64    let plan = Planner::build_plan(
65        storage.as_mut(),
66        &run,
67        opts.module_filter.as_deref(),
68        opts.target_filter.as_deref(),
69    )?;
70
71    if opts.dry_run {
72        return dry_run(&mut storage, &run, &plan, &opts);
73    }
74
75    let mut framework = build_framework(config_mgr, &opts)?;
76    if let Err(e) = framework.connect() {
77        // Record the whole plan as skipped so the attempt is auditable.
78        println!("[!] Could not connect to the exploitation framework: {e}");
79        for step in &plan {
80            persist(&mut storage, &run.stable_id, step, false, None, None)?;
81        }
82        println!("[!] Plan recorded as skipped; nothing was executed.");
83        return Ok(());
84    }
85    println!(
86        "[+] Connected to {} (workspace '{}')",
87        framework.name(),
88        framework.workspace()
89    );
90
91    // Verify every planned module exists on the connected instance. A module
92    // that is missing is skipped (auditable); a lookup that fails for any other
93    // RPC reason is also skipped but reported distinctly so the operator can
94    // tell "module absent" apart from "RPC down".
95    let mut verified: Vec<(ExploitStep, Option<ModuleInfo>)> = Vec::new();
96    for step in &plan {
97        match framework.module_info(&step.module_type, &step.module_name) {
98            Ok(info) => {
99                verified.push((step.clone(), Some(info)));
100            }
101            Err(crate::error::AppError::RpcModuleNotFound(_)) => {
102                println!(
103                    "[!] Module {} not present on the connected instance; marking skipped.",
104                    step.module_name
105                );
106                persist(
107                    &mut storage,
108                    &run.stable_id,
109                    step,
110                    false,
111                    Some(ExploitStatus::Skipped),
112                    None,
113                )?;
114            }
115            Err(e) => {
116                println!(
117                    "[!] Could not query module {} (RPC failure, not a missing module): {e}",
118                    step.module_name
119                );
120                persist(
121                    &mut storage,
122                    &run.stable_id,
123                    step,
124                    false,
125                    Some(ExploitStatus::Skipped),
126                    None,
127                )?;
128            }
129        }
130    }
131
132    if verified.is_empty() {
133        println!("[!] No modules are available on the connected instance.");
134        return Ok(());
135    }
136
137    render_plan(&verified);
138
139    // Interactive checklist: toggle modules, Enter to run the selected set.
140    let selected: Vec<bool> = if opts.yes {
141        vec![true; verified.len()]
142    } else {
143        select_steps(&verified)?
144    };
145
146    let chosen: Vec<(ExploitStep, Option<ModuleInfo>)> = verified
147        .into_iter()
148        .zip(selected.iter())
149        .filter(|(_, sel)| **sel)
150        .map(|(step, _)| step)
151        .collect();
152
153    if chosen.is_empty() {
154        println!("[!] No modules selected; recording plan as skipped.");
155        for step in &plan {
156            persist(&mut storage, &run.stable_id, step, false, None, None)?;
157        }
158        return Ok(());
159    }
160
161    if !opts.yes
162        && !prompt_bool_with_default(
163            &format!("Run {} selected module(s) now?", chosen.len()),
164            false,
165        )?
166    {
167        println!("[!] Execution declined; recording selection as skipped.");
168        for (step, _) in &chosen {
169            persist(
170                &mut storage,
171                &run.stable_id,
172                step,
173                true,
174                Some(ExploitStatus::Skipped),
175                None,
176            )?;
177        }
178        return Ok(());
179    }
180
181    // Execute each selected step sequentially.
182    println!();
183    for (step, info) in &chosen {
184        println!(
185            "[+] Executing {} against {} ...",
186            step.module_name, step.host
187        );
188
189        let options = resolve_options(info.as_ref(), step, opts.yes)?;
190        let missing: Vec<String> = options
191            .iter()
192            .filter(|(_, value)| value.is_empty())
193            .map(|(key, _)| key.clone())
194            .collect();
195        if !missing.is_empty() {
196            println!(
197                "[!] Required options missing ({}); skipping {}.",
198                missing.join(", "),
199                step.module_name
200            );
201            persist(
202                &mut storage,
203                &run.stable_id,
204                step,
205                true,
206                Some(ExploitStatus::Skipped),
207                None,
208            )?;
209            continue;
210        }
211
212        let mut step_clone = step.clone();
213        step_clone.inferred_options = options;
214        let payload = opts.payload_override.as_deref();
215        match execute_step(
216            framework.as_mut(),
217            &step_clone,
218            payload,
219            Duration::from_secs(job_timeout),
220        ) {
221            Ok(outcome) => {
222                render_outcome(&outcome);
223                persist(
224                    &mut storage,
225                    &run.stable_id,
226                    &outcome.step,
227                    true,
228                    Some(outcome.status),
229                    Some(&outcome),
230                )?;
231            }
232            Err(e) => {
233                println!("[!] Execution error for {}: {e}", step.module_name);
234                persist(
235                    &mut storage,
236                    &run.stable_id,
237                    step,
238                    true,
239                    Some(ExploitStatus::Failed),
240                    None,
241                )?;
242            }
243        }
244    }
245
246    framework.disconnect();
247    println!();
248    println!("[+] Exploit session complete. Records written to the database.");
249    Ok(())
250}
251
252fn build_framework(
253    config_mgr: &ConfigManager,
254    opts: &ExploitOptions,
255) -> Result<Box<dyn ExploitFramework>> {
256    if !config_mgr.is_configured() {
257        return Err(AppError::Message(
258            "no MSF-RPC configuration found; run 'ploit-malper setup' first".to_string(),
259        ));
260    }
261    let cfg = config_mgr.get_msfrpc_config().clone();
262    let mut framework =
263        MsfFramework::new(&cfg.host, cfg.port, &cfg.username, &cfg.password, cfg.ssl);
264    framework.set_workspace(opts.workspace.as_deref().unwrap_or(&cfg.workspace));
265    Ok(Box::new(framework))
266}
267
268/// Dry-run: show the full plan with module metadata and record it as previews.
269/// Never contacts the framework.
270fn dry_run(
271    storage: &mut Box<dyn crate::db::Storage>,
272    run: &crate::db::models::ScanRun,
273    plan: &[ExploitStep],
274    opts: &ExploitOptions,
275) -> Result<()> {
276    println!();
277    println!("[+] Dry run — no modules will be executed.");
278    render_plan_dry(plan);
279    println!();
280    if let Some(payload) = &opts.payload_override {
281        println!("[+] Payload override: {payload}");
282    }
283    println!("[+] Recording {} step(s) as dry_run previews.", plan.len());
284    for step in plan {
285        persist(
286            storage,
287            &run.stable_id,
288            step,
289            false,
290            Some(ExploitStatus::DryRun),
291            None,
292        )?;
293    }
294    println!("[+] Preview recorded; nothing was executed.");
295    Ok(())
296}
297
298fn render_plan(verified: &[(ExploitStep, Option<ModuleInfo>)]) {
299    println!();
300    println!("Exploitation plan (verified against the connected instance):");
301    println!(
302        "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
303        "#", "TYPE", "RANK", "CONF", "MODULE", "MISSING", "PAYLOAD"
304    );
305    println!("{}", "-".repeat(90));
306    for (i, (step, info)) in verified.iter().enumerate() {
307        let missing = if step.missing_options.is_empty() {
308            "none".to_string()
309        } else {
310            step.missing_options.join(",")
311        };
312        println!(
313            "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
314            i + 1,
315            step.module_type,
316            step.rank,
317            step.confidence,
318            short(&step.module_name, 16),
319            short(&missing, 6),
320            short(&step.payload_compatibility, 10),
321        );
322        println!(
323            "      host={} port={} cve={} vuln={}",
324            step.host,
325            step.service_port
326                .map(|p| p.to_string())
327                .unwrap_or_else(|| "-".to_string()),
328            step.cve.as_deref().unwrap_or("-"),
329            short(&step.vulnerability, 60),
330        );
331        if !step.inferred_options.is_empty() {
332            let opts = step
333                .inferred_options
334                .iter()
335                .map(|(k, v)| format!("{}={}", k, v))
336                .collect::<Vec<_>>()
337                .join(" ");
338            println!("      options: {opts}");
339        }
340        if let Some(info) = info {
341            if !info.description.is_empty() {
342                println!("      desc: {}", short(&info.description, 80));
343            }
344            let defaults = info
345                .options
346                .iter()
347                .filter(|o| o.default.is_some())
348                .map(|o| format!("{}={}", o.name, o.default.as_deref().unwrap_or("")))
349                .collect::<Vec<_>>()
350                .join(" ");
351            if !defaults.is_empty() {
352                println!("      defaults: {defaults}");
353            }
354        }
355    }
356}
357
358fn render_plan_dry(plan: &[ExploitStep]) {
359    println!();
360    println!("Planned modules (offline metadata):");
361    println!(
362        "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
363        "#", "TYPE", "RANK", "MODULE", "CVE", "CONF", "PAYLOAD"
364    );
365    println!("{}", "-".repeat(90));
366    for (i, step) in plan.iter().enumerate() {
367        println!(
368            "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
369            i + 1,
370            step.module_type,
371            step.rank,
372            short(&step.module_name, 16),
373            short(step.cve.as_deref().unwrap_or("-"), 16),
374            step.confidence,
375            short(&step.payload_compatibility, 10),
376        );
377        println!(
378            "      target={} port={} vuln={}",
379            step.host,
380            step.service_port
381                .map(|p| p.to_string())
382                .unwrap_or_else(|| "-".to_string()),
383            short(&step.vulnerability, 60),
384        );
385        if !step.missing_options.is_empty() {
386            println!(
387                "      required options to fill: {}",
388                step.missing_options.join(", ")
389            );
390        } else {
391            println!("      required options: fully inferred");
392        }
393    }
394}
395
396fn render_outcome(outcome: &ExploitOutcome) {
397    let status = outcome.status.as_str();
398    println!("      -> {} status={}", outcome.step.module_name, status);
399    if let Some(job) = &outcome.job_id {
400        println!("         job_id={}", job);
401    }
402    if let Some(session) = &outcome.session_id {
403        println!("         session_id={}", session);
404    }
405    if !outcome.loot.is_empty() {
406        println!("         loot={}", outcome.loot.join(","));
407    }
408    if let Some(error) = &outcome.error {
409        println!("         error={}", short(error, 120));
410    }
411}
412
413fn select_steps(verified: &[(ExploitStep, Option<ModuleInfo>)]) -> Result<Vec<bool>> {
414    // Interactive TUI: a circular picker where the cursor wraps around the
415    // ring (↑/k and ↓/j), space toggles, enter advances to the next step.
416    if crate::frontend::tui::interactive() {
417        let rows: Vec<crate::frontend::tui::PickerRow> = verified
418            .iter()
419            .map(|(step, _)| crate::frontend::tui::PickerRow {
420                text: step.module_name.clone(),
421                detail: Some(format!(
422                    "rank={} conf={} host={} port={}",
423                    step.rank,
424                    step.confidence,
425                    step.host,
426                    step.service_port
427                        .map(|p| p.to_string())
428                        .unwrap_or_else(|| "-".to_string())
429                )),
430                selected: step.default_selected,
431            })
432            .collect();
433
434        let first = verified
435            .iter()
436            .position(|(s, _)| s.default_selected)
437            .unwrap_or(0);
438        return match crate::frontend::tui::pick("Select modules to exploit", rows, first)? {
439            Some(chosen) => Ok(verified
440                .iter()
441                .enumerate()
442                .map(|(i, _)| chosen.contains(&i))
443                .collect()),
444            None => Ok(verified.iter().map(|_| false).collect()),
445        };
446    }
447
448    // Non-interactive fallback: the classic checklist prompt.
449    let mut selected: Vec<bool> = verified.iter().map(|(s, _)| s.default_selected).collect();
450    loop {
451        println!();
452        println!("Module checklist — [x] = recommended, [ ] = optional:");
453        for (i, (step, _)) in verified.iter().enumerate() {
454            let mark = if selected[i] { "x" } else { " " };
455            println!(
456                "  [{}] {:<3} {}  [{}] host={}",
457                mark,
458                i + 1,
459                step.module_name,
460                step.rank,
461                step.host
462            );
463        }
464        print!("  Toggle (e.g. 1,3), 'a' all, 'n' none, or press Enter to run the selected set: ");
465        io::stdout().flush()?;
466        let mut buffer = String::new();
467        io::stdin().read_line(&mut buffer)?;
468        let input = buffer.trim().to_lowercase();
469        if input.is_empty() {
470            break;
471        }
472        match input.as_str() {
473            "a" => selected.iter_mut().for_each(|s| *s = true),
474            "n" => selected.iter_mut().for_each(|s| *s = false),
475            _ => {
476                for token in input.split([',', ' ', '\t']) {
477                    if token.is_empty() {
478                        continue;
479                    }
480                    match token.parse::<usize>() {
481                        Ok(idx) if idx >= 1 && idx <= selected.len() => {
482                            let slot = &mut selected[idx - 1];
483                            *slot = !*slot;
484                        }
485                        _ => println!("    ignoring '{}' (not a module number)", token),
486                    }
487                }
488            }
489        }
490    }
491    Ok(selected)
492}
493
494/// Fill any required option not already inferred. Without `--yes` the missing
495/// options are prompted for; with `--yes` framework defaults are used and the
496/// value is left empty when no default exists.
497fn resolve_options(
498    info: Option<&ModuleInfo>,
499    step: &ExploitStep,
500    yes: bool,
501) -> Result<std::collections::BTreeMap<String, String>> {
502    let mut options = step.inferred_options.clone();
503
504    let required: Vec<String> = match info {
505        Some(info) => info.required_options.to_vec(),
506        None => step.missing_options.clone(),
507    };
508
509    let default_for = |name: &str| -> Option<String> {
510        info.and_then(|info| {
511            info.options
512                .iter()
513                .find(|o| o.name == name)
514                .and_then(|o| o.default.clone())
515        })
516    };
517
518    for name in required {
519        if options.contains_key(&name) {
520            continue;
521        }
522        let default = default_for(&name);
523        if yes {
524            if let Some(default) = default {
525                options.insert(name.clone(), default);
526            } else {
527                options.insert(name.clone(), String::new());
528            }
529            continue;
530        }
531        let value = prompt_text(
532            &format!("Required option {} for {}", name, step.module_name),
533            &default.unwrap_or_default(),
534        )?;
535        options.insert(name.clone(), value);
536    }
537
538    Ok(options)
539}
540
541/// Persist one step as an `ExploitExecution` record. When `outcome` is given,
542/// the record reflects the finished execution; otherwise it reflects the
543/// planned/skipped state.
544fn persist(
545    storage: &mut Box<dyn crate::db::Storage>,
546    run_id: &str,
547    step: &ExploitStep,
548    selected: bool,
549    status: Option<ExploitStatus>,
550    outcome: Option<&ExploitOutcome>,
551) -> Result<()> {
552    let mut exec =
553        ExploitExecution::new(run_id, &step.asset_id, &step.finding_id, &step.module_name);
554    exec.module_type = step.module_type.clone();
555    exec.host = step.host.clone();
556    exec.options = step.inferred_options.clone();
557    exec.selected = selected;
558    match outcome {
559        Some(outcome) => {
560            exec.status = outcome.status.as_str().to_string();
561            exec.start_time = outcome.start_time.clone();
562            exec.finish_time = outcome.finish_time.clone();
563            exec.job_id = outcome.job_id.clone();
564            exec.session_id = outcome.session_id.clone();
565            exec.error = outcome.error.clone();
566            exec.loot = outcome.loot.clone();
567        }
568        None => {
569            exec.status = status
570                .unwrap_or(if selected {
571                    ExploitStatus::Planned
572                } else {
573                    ExploitStatus::Skipped
574                })
575                .as_str()
576                .to_string();
577        }
578    }
579    storage.upsert_exploit_execution(&exec)
580}
581
582fn prompt_text(prompt: &str, default: &str) -> Result<String> {
583    print!("  {} [{}]: ", prompt, default);
584    io::stdout().flush()?;
585    let mut buffer = String::new();
586    io::stdin().read_line(&mut buffer)?;
587    let value = buffer.trim();
588    Ok(if value.is_empty() {
589        default.to_string()
590    } else {
591        value.to_string()
592    })
593}
594
595fn prompt_bool_with_default(prompt: &str, default: bool) -> Result<bool> {
596    let suffix = if default { "[Y/n]" } else { "[y/N]" };
597    loop {
598        print!("  {} {}: ", prompt, suffix);
599        io::stdout().flush()?;
600        let mut buffer = String::new();
601        io::stdin().read_line(&mut buffer)?;
602        let value = buffer.trim().to_lowercase();
603        if value.is_empty() {
604            return Ok(default);
605        }
606        match value.as_str() {
607            "y" | "yes" | "true" => return Ok(true),
608            "n" | "no" | "false" => return Ok(false),
609            _ => println!("  Please answer yes or no."),
610        }
611    }
612}
613
614fn short(value: &str, width: usize) -> String {
615    let count = value.chars().count();
616    if count <= width {
617        return value.to_string();
618    }
619    if width <= 1 {
620        return "…".to_string();
621    }
622    let mut out: String = value.chars().take(width - 1).collect();
623    out.push('…');
624    out
625}