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.
92    let mut verified: Vec<(ExploitStep, Option<ModuleInfo>)> = Vec::new();
93    for step in &plan {
94        match framework.module_exists(&step.module_type, &step.module_name) {
95            Ok(true) => {
96                let info = framework
97                    .module_info(&step.module_type, &step.module_name)
98                    .ok();
99                verified.push((step.clone(), info));
100            }
101            Ok(false) => {
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!("[!] Could not query module {}: {e}", step.module_name);
117                persist(
118                    &mut storage,
119                    &run.stable_id,
120                    step,
121                    false,
122                    Some(ExploitStatus::Skipped),
123                    None,
124                )?;
125            }
126        }
127    }
128
129    if verified.is_empty() {
130        println!("[!] No modules are available on the connected instance.");
131        return Ok(());
132    }
133
134    render_plan(&verified);
135
136    // Interactive checklist: toggle modules, Enter to run the selected set.
137    let selected: Vec<bool> = if opts.yes {
138        vec![true; verified.len()]
139    } else {
140        select_steps(&verified)?
141    };
142
143    let chosen: Vec<(ExploitStep, Option<ModuleInfo>)> = verified
144        .into_iter()
145        .zip(selected.iter())
146        .filter(|(_, sel)| **sel)
147        .map(|(step, _)| step)
148        .collect();
149
150    if chosen.is_empty() {
151        println!("[!] No modules selected; recording plan as skipped.");
152        for step in &plan {
153            persist(&mut storage, &run.stable_id, step, false, None, None)?;
154        }
155        return Ok(());
156    }
157
158    if !opts.yes
159        && !prompt_bool_with_default(
160            &format!("Run {} selected module(s) now?", chosen.len()),
161            false,
162        )?
163    {
164        println!("[!] Execution declined; recording selection as skipped.");
165        for (step, _) in &chosen {
166            persist(
167                &mut storage,
168                &run.stable_id,
169                step,
170                true,
171                Some(ExploitStatus::Skipped),
172                None,
173            )?;
174        }
175        return Ok(());
176    }
177
178    // Execute each selected step sequentially.
179    println!();
180    for (step, info) in &chosen {
181        println!(
182            "[+] Executing {} against {} ...",
183            step.module_name, step.host
184        );
185
186        let options = resolve_options(info.as_ref(), step, opts.yes)?;
187        let missing: Vec<String> = options
188            .iter()
189            .filter(|(_, value)| value.is_empty())
190            .map(|(key, _)| key.clone())
191            .collect();
192        if !missing.is_empty() {
193            println!(
194                "[!] Required options missing ({}); skipping {}.",
195                missing.join(", "),
196                step.module_name
197            );
198            persist(
199                &mut storage,
200                &run.stable_id,
201                step,
202                true,
203                Some(ExploitStatus::Skipped),
204                None,
205            )?;
206            continue;
207        }
208
209        let mut step_clone = step.clone();
210        step_clone.inferred_options = options;
211        let payload = opts.payload_override.as_deref();
212        match execute_step(
213            framework.as_mut(),
214            &step_clone,
215            payload,
216            Duration::from_secs(job_timeout),
217        ) {
218            Ok(outcome) => {
219                render_outcome(&outcome);
220                persist(
221                    &mut storage,
222                    &run.stable_id,
223                    &outcome.step,
224                    true,
225                    Some(outcome.status),
226                    Some(&outcome),
227                )?;
228            }
229            Err(e) => {
230                println!("[!] Execution error for {}: {e}", step.module_name);
231                persist(
232                    &mut storage,
233                    &run.stable_id,
234                    step,
235                    true,
236                    Some(ExploitStatus::Failed),
237                    None,
238                )?;
239            }
240        }
241    }
242
243    framework.disconnect();
244    println!();
245    println!("[+] Exploit session complete. Records written to the database.");
246    Ok(())
247}
248
249fn build_framework(
250    config_mgr: &ConfigManager,
251    opts: &ExploitOptions,
252) -> Result<Box<dyn ExploitFramework>> {
253    if !config_mgr.is_configured() {
254        return Err(AppError::Message(
255            "no MSF-RPC configuration found; run 'ploit-malper setup' first".to_string(),
256        ));
257    }
258    let cfg = config_mgr.get_msfrpc_config().clone();
259    let mut framework =
260        MsfFramework::new(&cfg.host, cfg.port, &cfg.username, &cfg.password, cfg.ssl);
261    framework.set_workspace(opts.workspace.as_deref().unwrap_or(&cfg.workspace));
262    Ok(Box::new(framework))
263}
264
265/// Dry-run: show the full plan with module metadata and record it as previews.
266/// Never contacts the framework.
267fn dry_run(
268    storage: &mut Box<dyn crate::db::Storage>,
269    run: &crate::db::models::ScanRun,
270    plan: &[ExploitStep],
271    opts: &ExploitOptions,
272) -> Result<()> {
273    println!();
274    println!("[+] Dry run — no modules will be executed.");
275    render_plan_dry(plan);
276    println!();
277    if let Some(payload) = &opts.payload_override {
278        println!("[+] Payload override: {payload}");
279    }
280    println!("[+] Recording {} step(s) as dry_run previews.", plan.len());
281    for step in plan {
282        persist(
283            storage,
284            &run.stable_id,
285            step,
286            false,
287            Some(ExploitStatus::DryRun),
288            None,
289        )?;
290    }
291    println!("[+] Preview recorded; nothing was executed.");
292    Ok(())
293}
294
295fn render_plan(verified: &[(ExploitStep, Option<ModuleInfo>)]) {
296    println!();
297    println!("Exploitation plan (verified against the connected instance):");
298    println!(
299        "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
300        "#", "TYPE", "RANK", "CONF", "MODULE", "MISSING", "PAYLOAD"
301    );
302    println!("{}", "-".repeat(90));
303    for (i, (step, info)) in verified.iter().enumerate() {
304        let missing = if step.missing_options.is_empty() {
305            "none".to_string()
306        } else {
307            step.missing_options.join(",")
308        };
309        println!(
310            "{:<4} {:<8} {:<6} {:<5} {:<16} {:<6} {:<10}",
311            i + 1,
312            step.module_type,
313            step.rank,
314            step.confidence,
315            short(&step.module_name, 16),
316            short(&missing, 6),
317            short(&step.payload_compatibility, 10),
318        );
319        println!(
320            "      host={} port={} cve={} vuln={}",
321            step.host,
322            step.service_port
323                .map(|p| p.to_string())
324                .unwrap_or_else(|| "-".to_string()),
325            step.cve.as_deref().unwrap_or("-"),
326            short(&step.vulnerability, 60),
327        );
328        if !step.inferred_options.is_empty() {
329            let opts = step
330                .inferred_options
331                .iter()
332                .map(|(k, v)| format!("{}={}", k, v))
333                .collect::<Vec<_>>()
334                .join(" ");
335            println!("      options: {opts}");
336        }
337        if let Some(info) = info {
338            if !info.description.is_empty() {
339                println!("      desc: {}", short(&info.description, 80));
340            }
341            let defaults = info
342                .options
343                .iter()
344                .filter(|o| o.default.is_some())
345                .map(|o| format!("{}={}", o.name, o.default.as_deref().unwrap_or("")))
346                .collect::<Vec<_>>()
347                .join(" ");
348            if !defaults.is_empty() {
349                println!("      defaults: {defaults}");
350            }
351        }
352    }
353}
354
355fn render_plan_dry(plan: &[ExploitStep]) {
356    println!();
357    println!("Planned modules (offline metadata):");
358    println!(
359        "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
360        "#", "TYPE", "RANK", "MODULE", "CVE", "CONF", "PAYLOAD"
361    );
362    println!("{}", "-".repeat(90));
363    for (i, step) in plan.iter().enumerate() {
364        println!(
365            "{:<4} {:<8} {:<6} {:<16} {:<16} {:<6} {:<10}",
366            i + 1,
367            step.module_type,
368            step.rank,
369            short(&step.module_name, 16),
370            short(step.cve.as_deref().unwrap_or("-"), 16),
371            step.confidence,
372            short(&step.payload_compatibility, 10),
373        );
374        println!(
375            "      target={} port={} vuln={}",
376            step.host,
377            step.service_port
378                .map(|p| p.to_string())
379                .unwrap_or_else(|| "-".to_string()),
380            short(&step.vulnerability, 60),
381        );
382        if !step.missing_options.is_empty() {
383            println!(
384                "      required options to fill: {}",
385                step.missing_options.join(", ")
386            );
387        } else {
388            println!("      required options: fully inferred");
389        }
390    }
391}
392
393fn render_outcome(outcome: &ExploitOutcome) {
394    let status = outcome.status.as_str();
395    println!("      -> {} status={}", outcome.step.module_name, status);
396    if let Some(job) = &outcome.job_id {
397        println!("         job_id={}", job);
398    }
399    if let Some(session) = &outcome.session_id {
400        println!("         session_id={}", session);
401    }
402    if !outcome.loot.is_empty() {
403        println!("         loot={}", outcome.loot.join(","));
404    }
405    if let Some(error) = &outcome.error {
406        println!("         error={}", short(error, 120));
407    }
408}
409
410fn select_steps(verified: &[(ExploitStep, Option<ModuleInfo>)]) -> Result<Vec<bool>> {
411    let mut selected: Vec<bool> = verified.iter().map(|(s, _)| s.default_selected).collect();
412    loop {
413        println!();
414        println!("Module checklist — [x] = recommended, [ ] = optional:");
415        for (i, (step, _)) in verified.iter().enumerate() {
416            let mark = if selected[i] { "x" } else { " " };
417            println!(
418                "  [{}] {:<3} {}  [{}] host={}",
419                mark,
420                i + 1,
421                step.module_name,
422                step.rank,
423                step.host
424            );
425        }
426        print!("  Toggle (e.g. 1,3), 'a' all, 'n' none, or press Enter to run the selected set: ");
427        io::stdout().flush()?;
428        let mut buffer = String::new();
429        io::stdin().read_line(&mut buffer)?;
430        let input = buffer.trim().to_lowercase();
431        if input.is_empty() {
432            break;
433        }
434        match input.as_str() {
435            "a" => selected.iter_mut().for_each(|s| *s = true),
436            "n" => selected.iter_mut().for_each(|s| *s = false),
437            _ => {
438                for token in input.split([',', ' ', '\t']) {
439                    if token.is_empty() {
440                        continue;
441                    }
442                    match token.parse::<usize>() {
443                        Ok(idx) if idx >= 1 && idx <= selected.len() => {
444                            let slot = &mut selected[idx - 1];
445                            *slot = !*slot;
446                        }
447                        _ => println!("    ignoring '{}' (not a module number)", token),
448                    }
449                }
450            }
451        }
452    }
453    Ok(selected)
454}
455
456/// Fill any required option not already inferred. Without `--yes` the missing
457/// options are prompted for; with `--yes` framework defaults are used and the
458/// value is left empty when no default exists.
459fn resolve_options(
460    info: Option<&ModuleInfo>,
461    step: &ExploitStep,
462    yes: bool,
463) -> Result<std::collections::BTreeMap<String, String>> {
464    let mut options = step.inferred_options.clone();
465
466    let required: Vec<String> = match info {
467        Some(info) => info.required_options.to_vec(),
468        None => step.missing_options.clone(),
469    };
470
471    let default_for = |name: &str| -> Option<String> {
472        info.and_then(|info| {
473            info.options
474                .iter()
475                .find(|o| o.name == name)
476                .and_then(|o| o.default.clone())
477        })
478    };
479
480    for name in required {
481        if options.contains_key(&name) {
482            continue;
483        }
484        let default = default_for(&name);
485        if yes {
486            if let Some(default) = default {
487                options.insert(name.clone(), default);
488            } else {
489                options.insert(name.clone(), String::new());
490            }
491            continue;
492        }
493        let value = prompt_text(
494            &format!("Required option {} for {}", name, step.module_name),
495            &default.unwrap_or_default(),
496        )?;
497        options.insert(name.clone(), value);
498    }
499
500    Ok(options)
501}
502
503/// Persist one step as an `ExploitExecution` record. When `outcome` is given,
504/// the record reflects the finished execution; otherwise it reflects the
505/// planned/skipped state.
506fn persist(
507    storage: &mut Box<dyn crate::db::Storage>,
508    run_id: &str,
509    step: &ExploitStep,
510    selected: bool,
511    status: Option<ExploitStatus>,
512    outcome: Option<&ExploitOutcome>,
513) -> Result<()> {
514    let mut exec =
515        ExploitExecution::new(run_id, &step.asset_id, &step.finding_id, &step.module_name);
516    exec.module_type = step.module_type.clone();
517    exec.host = step.host.clone();
518    exec.options = step.inferred_options.clone();
519    exec.selected = selected;
520    match outcome {
521        Some(outcome) => {
522            exec.status = outcome.status.as_str().to_string();
523            exec.start_time = outcome.start_time.clone();
524            exec.finish_time = outcome.finish_time.clone();
525            exec.job_id = outcome.job_id.clone();
526            exec.session_id = outcome.session_id.clone();
527            exec.error = outcome.error.clone();
528            exec.loot = outcome.loot.clone();
529        }
530        None => {
531            exec.status = status
532                .unwrap_or(if selected {
533                    ExploitStatus::Planned
534                } else {
535                    ExploitStatus::Skipped
536                })
537                .as_str()
538                .to_string();
539        }
540    }
541    storage.upsert_exploit_execution(&exec)
542}
543
544fn prompt_text(prompt: &str, default: &str) -> Result<String> {
545    print!("  {} [{}]: ", prompt, default);
546    io::stdout().flush()?;
547    let mut buffer = String::new();
548    io::stdin().read_line(&mut buffer)?;
549    let value = buffer.trim();
550    Ok(if value.is_empty() {
551        default.to_string()
552    } else {
553        value.to_string()
554    })
555}
556
557fn prompt_bool_with_default(prompt: &str, default: bool) -> Result<bool> {
558    let suffix = if default { "[Y/n]" } else { "[y/N]" };
559    loop {
560        print!("  {} {}: ", prompt, suffix);
561        io::stdout().flush()?;
562        let mut buffer = String::new();
563        io::stdin().read_line(&mut buffer)?;
564        let value = buffer.trim().to_lowercase();
565        if value.is_empty() {
566            return Ok(default);
567        }
568        match value.as_str() {
569            "y" | "yes" | "true" => return Ok(true),
570            "n" | "no" | "false" => return Ok(false),
571            _ => println!("  Please answer yes or no."),
572        }
573    }
574}
575
576fn short(value: &str, width: usize) -> String {
577    let count = value.chars().count();
578    if count <= width {
579        return value.to_string();
580    }
581    if width <= 1 {
582        return "…".to_string();
583    }
584    let mut out: String = value.chars().take(width - 1).collect();
585    out.push('…');
586    out
587}