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 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 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 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 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
268fn 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 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 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
494fn 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
541fn 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}