Skip to main content

ploit_malper/
cli.rs

1use std::env;
2use std::io::{self, Write};
3use std::path::PathBuf;
4
5use crate::config::ConfigManager;
6use crate::db::Storage;
7use crate::engine::msfrpc;
8use crate::error::{AppError, Result};
9use crate::models::PayloadRecipe;
10use crate::recipe;
11use crate::report;
12
13pub fn main() {
14    if let Err(err) = run() {
15        eprintln!("[!] {}", err);
16        std::process::exit(1);
17    }
18}
19
20pub fn run() -> Result<()> {
21    let mut config_mgr = ConfigManager::new();
22    let config_loaded = config_mgr.load();
23
24    let args = env::args().skip(1).collect::<Vec<_>>();
25    if args.is_empty() {
26        print_banner();
27        return Ok(());
28    }
29
30    match args[0].as_str() {
31        "process" => {
32            cmd_process(&args[1..], &mut config_mgr, config_loaded)?;
33        }
34        "reset-config" => {
35            cmd_reset_config(&mut config_mgr)?;
36        }
37        "setup" => {
38            cmd_setup(&mut config_mgr)?;
39        }
40        "db_setup" => {
41            cmd_db_setup(&mut config_mgr)?;
42        }
43        "ingest" => {
44            cmd_ingest(&args[1..], &mut config_mgr)?;
45        }
46        "assets" => {
47            crate::frontend::assets::cmd_assets(&args[1..], &mut config_mgr)?;
48        }
49        "services" => {
50            crate::frontend::services::cmd_services(&args[1..], &mut config_mgr)?;
51        }
52        "findings" => {
53            crate::frontend::findings::cmd_findings(&args[1..], &mut config_mgr)?;
54        }
55        "history" => {
56            crate::frontend::history::cmd_history(&args[1..], &mut config_mgr)?;
57        }
58        "runs" => {
59            crate::frontend::runs::cmd_runs(&args[1..], &mut config_mgr)?;
60        }
61        "diff" => {
62            crate::frontend::diff::cmd_diff(&args[1..], &mut config_mgr)?;
63        }
64        "del" => {
65            cmd_del(&args[1..], &mut config_mgr)?;
66        }
67        "exploit" => {
68            crate::exploit::cli::cmd_exploit(&args[1..], &mut config_mgr)?;
69        }
70        "-h" | "--help" => {
71            print_banner();
72        }
73        other => {
74            eprintln!("[!] Unknown command: {}", other);
75            print_banner();
76        }
77    }
78
79    Ok(())
80}
81
82fn cmd_setup(config_mgr: &mut ConfigManager) -> Result<()> {
83    println!("PloitMalper Configuration Setup");
84    println!();
85    println!("--- MSF-RPC Configuration ---");
86    println!("Enter your Metasploit RPC server details.");
87    let current = config_mgr.get_msfrpc_config().clone();
88
89    let host = prompt_text("MSF-RPC Host", &current.host)?;
90    let port = prompt_u16("MSF-RPC Port", current.port)?;
91    let username = prompt_text("MSF-RPC Username", &current.username)?;
92    let password = prompt_text("MSF-RPC Password", &current.password)?;
93    let ssl = prompt_bool("Use SSL?", current.ssl)?;
94    let workspace = prompt_text("Workspace", &current.workspace)?;
95
96    config_mgr.config.msfrpc.host = host;
97    config_mgr.config.msfrpc.port = port;
98    config_mgr.config.msfrpc.username = username;
99    config_mgr.config.msfrpc.password = password;
100    config_mgr.config.msfrpc.ssl = ssl;
101    config_mgr.config.msfrpc.workspace = workspace;
102
103    println!();
104    println!("--- NVD API Configuration ---");
105    println!("Enter your NVD API key for CVE enrichment (optional but recommended).");
106    println!("Get one at: https://nvd.nist.gov/developers/request-an-api-key");
107    let current_nvd = config_mgr.get_nvd_config().clone();
108    let nvd_key = prompt_text("NVD API Key", &current_nvd.api_key)?;
109    config_mgr.config.nvd.api_key = nvd_key;
110
111    config_mgr.save()?;
112    println!();
113    println!(
114        "[+] Configuration saved to {}",
115        crate::config::config_file().display()
116    );
117    Ok(())
118}
119
120fn cmd_process(args: &[String], config_mgr: &mut ConfigManager, config_loaded: bool) -> Result<()> {
121    let verbose = args.iter().any(|a| a == "--verbose" || a == "-v");
122    let input_file = match args.iter().find(|a| !a.starts_with('-')) {
123        Some(path) => path,
124        None => {
125            eprintln!("[!] No input file specified. Use: ploit-malper process <scan.json>");
126            return Ok(());
127        }
128    };
129
130    let input_path = PathBuf::from(input_file);
131    if !input_path.exists() {
132        eprintln!("[!] File not found: {}", input_path.display());
133        return Ok(());
134    }
135
136    let process_opts = crate::process::ProcessOptions {
137        verbose,
138        print_console: true,
139    };
140    let processed = crate::process::process_scan_file(&input_path, config_mgr, &process_opts)?;
141    let records = processed.records;
142    let injectable = processed.injectable;
143    let all_suggestions = processed.suggestions;
144    let dedup_stats = processed.dedup_stats;
145
146    let mut msf_connected = false;
147    let mut msf_client = None;
148    if !config_loaded || !config_mgr.is_configured() {
149        println!("[?] No MSF-RPC configuration found.");
150        if prompt_bool_with_default("Configure MSF-RPC and NVD API now?", true)? {
151            cmd_setup(config_mgr)?;
152        } else {
153            println!("[?] Continuing without MSF-RPC integration.");
154            println!();
155        }
156    }
157
158    if config_mgr.is_configured() {
159        let msf_cfg = config_mgr.get_msfrpc_config().clone();
160        println!();
161        println!("[+] Connecting to MSF-RPC for workspace verification...");
162        let mut client = msfrpc::MsfRpcClient::new(
163            &msf_cfg.host,
164            msf_cfg.port,
165            &msf_cfg.username,
166            &msf_cfg.password,
167            msf_cfg.ssl,
168        );
169
170        let login_result = client.login()?;
171        if login_result.success {
172            msf_connected = true;
173            println!(
174                "[+] Authenticated to MSF-RPC at {}:{}",
175                msf_cfg.host, msf_cfg.port
176            );
177
178            if verbose {
179                println!("[dbg] fetching available workspaces...");
180            }
181            let ws_result = client.get_workspaces()?;
182            if !ws_result.workspaces.is_empty() {
183                println!();
184                println!("MSF Workspaces");
185                println!("{:<24} {:<16} {:>8}", "Name", "Scope", "Hosts");
186                println!("{}", "-".repeat(52));
187                for workspace in &ws_result.workspaces {
188                    println!(
189                        "{:<24} {:<16} {:>8}",
190                        truncate(&workspace.name, 24),
191                        truncate(&workspace.scope, 16),
192                        workspace.host_count
193                    );
194                }
195            }
196
197            for record in records.iter().take(5) {
198                if let Some(target) = record.target.as_str().split_whitespace().next() {
199                    if verbose {
200                        println!(
201                            "[dbg] checking host {} against workspace '{}'...",
202                            target, msf_cfg.workspace
203                        );
204                    }
205                    let check_result =
206                        client.check_host_exists(target, Some(&msf_cfg.workspace))?;
207                    if check_result.exists {
208                        println!(
209                            "[?] Host {} already exists in workspace '{}'",
210                            target, msf_cfg.workspace
211                        );
212                    }
213                }
214            }
215        } else {
216            println!(
217                "[!] Remote MSF-RPC unreachable. Switched entirely to offline matching matrix."
218            );
219            println!("    Error: {}", login_result.error);
220        }
221
222        msf_client = Some(client);
223    }
224
225    let mut recipes: Vec<PayloadRecipe> = Vec::new();
226    if prompt_bool_with_default("\nGenerate msfvenom payload recipes?", false)? {
227        println!();
228        println!("Payload Recipe Builder");
229        let platforms = recipe::get_available_platforms();
230        println!("Available platforms: {}", platforms.join(", "));
231        let platform = prompt_choice("Platform", &platforms, "windows")?;
232        let arches = recipe::get_available_arches(&platform);
233        println!("Available architectures: {}", arches.join(", "));
234        let arch = prompt_choice("Architecture", &arches, "x64")?;
235        let lhost = prompt_text("LHOST", "10.0.0.1")?;
236        let lport = prompt_u16("LPORT", 4444)?;
237        let output = prompt_text("Output path", "/tmp/payload")?;
238
239        let recipe = recipe::build_recipe(&platform, &arch, &lhost, lport, &output, None, None);
240        println!();
241        println!("Generated recipe:");
242        println!("{}", recipe.command);
243        recipes.push(recipe);
244    }
245
246    let report_path = report::derive_report_path(&input_path);
247    if prompt_bool_with_default(
248        &format!(
249            "\nWrite findings to Markdown report ({})?",
250            report_path.display()
251        ),
252        true,
253    )? {
254        let output_path = report::generate_markdown_report(
255            &records,
256            &injectable,
257            &all_suggestions,
258            &recipes,
259            &dedup_stats,
260            report_path.to_str().unwrap_or("report.md"),
261        )?;
262        println!();
263        println!("[+] Report written to: {}", output_path.display());
264    }
265
266    if msf_connected {
267        if let Some(mut client) = msf_client {
268            client.logout();
269        }
270    }
271
272    config_mgr.config.last_scan_file = Some(input_path.to_string_lossy().to_string());
273    config_mgr.save()?;
274    Ok(())
275}
276
277fn cmd_reset_config(config_mgr: &mut ConfigManager) -> Result<()> {
278    println!("[?] Resetting all stored configuration...");
279    config_mgr.reset()?;
280    println!("[+] Configuration reset. Run ploit-malper to set up credentials.");
281    Ok(())
282}
283
284fn cmd_db_setup(config_mgr: &mut ConfigManager) -> Result<()> {
285    let _verbose = std::env::args().any(|a| a == "--verbose" || a == "-v");
286
287    println!("PloitMalper Database Setup");
288    println!();
289    println!("The intelligence database stores scan artifacts (NetMalper, VulnMalper,");
290    println!("PloitMalper) with history tracking. PocketBase is the primary backend with");
291    println!("SQLite as the local fallback.");
292    println!();
293
294    let current = config_mgr.get_database_config().clone();
295    let backend_choices = vec!["pocketbase".to_string(), "sqlite".to_string()];
296    let backend = prompt_choice("Backend", &backend_choices, &current.backend)?;
297
298    let mut db = current.clone();
299    db.backend = backend;
300
301    if db.backend == "pocketbase" {
302        println!();
303        println!("--- PocketBase Configuration ---");
304        db.pocketbase_url = prompt_text("PocketBase URL", &db.pocketbase_url)?;
305        db.pocketbase_admin_email = prompt_text("Admin email", &db.pocketbase_admin_email)?;
306        db.pocketbase_admin_password =
307            prompt_text("Admin password", &db.pocketbase_admin_password)?;
308
309        println!();
310        println!("[+] Writing schema reference file...");
311        crate::db::schema::write_schema_file()?;
312        println!(
313            "[+] Schema reference written to {}",
314            crate::config::db_schema_file().display()
315        );
316
317        let mut storage = crate::db::pocketbase::PocketBaseStorage::new(&db)?;
318        if !storage.ping()? {
319            return Err(AppError::Message(format!(
320                "PocketBase is not reachable at {}",
321                db.pocketbase_url
322            )));
323        }
324        println!("[+] Connected to PocketBase at {}", db.pocketbase_url);
325
326        println!("[+] Authenticating as superuser...");
327        storage.login()?;
328        println!("[+] Authenticated.");
329
330        // Show a plan (existing vs missing collections) before mutating.
331        let existing = storage.list_collection_names()?;
332        let missing: Vec<&str> = crate::db::collections::ALL
333            .iter()
334            .filter(|name| !existing.iter().any(|e| e == *name))
335            .cloned()
336            .collect();
337        if missing.is_empty() {
338            println!(
339                "[+] All {} required collections already present.",
340                crate::db::collections::ALL.len()
341            );
342        } else {
343            println!("[+] Missing collections (will be created):");
344            for name in &missing {
345                println!("    - {}", name);
346            }
347        }
348        if !missing.is_empty() && prompt_bool_with_default("Create missing collections?", true)? {
349            storage.ensure_schema()?;
350            println!("[+] Schema applied.");
351        } else {
352            println!("[!] No collections created.");
353        }
354
355        db.pocketbase_token = storage.token();
356    } else {
357        println!();
358        println!("--- SQLite Configuration ---");
359        db.sqlite_path = prompt_text("SQLite database path", &db.sqlite_path)?;
360        println!("[+] Initializing SQLite database...");
361        let mut storage = crate::db::open_storage(&db)?;
362        storage.ensure_schema()?;
363        if !storage.ping()? {
364            return Err(AppError::Message(
365                "SQLite database could not be opened".to_string(),
366            ));
367        }
368        println!("[+] SQLite database ready.");
369    }
370
371    db.configured = true;
372    config_mgr.config.database = db;
373    config_mgr.save()?;
374    println!();
375    println!(
376        "[+] Database configuration saved to {}",
377        crate::config::config_file().display()
378    );
379    Ok(())
380}
381
382fn cmd_ingest(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
383    let mut folder: Option<PathBuf> = None;
384    let mut opts = crate::ingest::importer::IngestOptions::default();
385
386    let mut index = 0usize;
387    while index < args.len() {
388        match args[index].as_str() {
389            "--verbose" | "-v" => opts.verbose = true,
390            "--dry-run" => opts.dry_run = true,
391            "--force" | "-f" => opts.force = true,
392            "--process" | "-p" => opts.process = true,
393            "--backend" | "-b" => {
394                if let Some(value) = args.get(index + 1) {
395                    if !["pocketbase", "sqlite", "auto"].contains(&value.as_str()) {
396                        return Err(AppError::Message(format!(
397                            "invalid backend '{}' (use pocketbase, sqlite or auto)",
398                            value
399                        )));
400                    }
401                    opts.backend = value.clone();
402                    index += 1;
403                }
404            }
405            "--pocketbase-url" => {
406                if let Some(value) = args.get(index + 1) {
407                    opts.pocketbase_url = Some(value.clone());
408                    index += 1;
409                }
410            }
411            "--sqlite-path" => {
412                if let Some(value) = args.get(index + 1) {
413                    opts.sqlite_path = Some(value.clone());
414                    index += 1;
415                }
416            }
417            "-h" | "--help" => {
418                println!(
419                    "Usage: ploit-malper ingest <folder> [--verbose] [--dry-run] [--force] [--process] \
420                     [--backend pocketbase|sqlite|auto] [--pocketbase-url URL] [--sqlite-path PATH]"
421                );
422                return Ok(());
423            }
424            other if folder.is_none() && !other.starts_with('-') => {
425                folder = Some(PathBuf::from(other));
426            }
427            other => {
428                return Err(AppError::Message(format!(
429                    "unknown ingest option: {}",
430                    other
431                )));
432            }
433        }
434        index += 1;
435    }
436
437    let folder = folder.ok_or_else(|| {
438        AppError::Message(
439            "ingest requires a folder path (e.g. ploit-malper ingest ./results)".to_string(),
440        )
441    })?;
442
443    if !config_mgr.database_configured() {
444        return Err(AppError::Message(
445            "no database configured; run 'ploit-malper db_setup' first".to_string(),
446        ));
447    }
448
449    if opts.process && !opts.dry_run {
450        let processed = crate::process::process_folder(&folder, config_mgr, opts.verbose)?;
451        println!(
452            "[+] Pre-processed {} VulnMalper JSON file(s) into PloitMalper reports.",
453            processed
454        );
455        println!();
456    }
457
458    let summary =
459        crate::ingest::importer::ingest_folder(&folder, config_mgr.get_database_config(), &opts)?;
460
461    if opts.dry_run || summary.skipped_already_imported {
462        return Ok(());
463    }
464
465    println!();
466    println!("[+] Import summary:");
467    println!("    run_id:        {}", summary.run_id);
468    println!("    target:        {}", summary.target);
469    println!(
470        "    artifacts:     {} accepted, {} rejected",
471        summary.artifacts_accepted, summary.artifacts_rejected
472    );
473    println!("    assets:        {}", summary.assets);
474    println!("    services:      {}", summary.services);
475    println!("    findings:      {}", summary.findings);
476    println!("    observations:  {}", summary.observations);
477    Ok(())
478}
479
480fn cmd_del(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
481    let parsed = crate::frontend::Args::parse(args)?;
482    if parsed.has("help") {
483        println!(
484            "Usage: ploit-malper del <run_id> [--yes] [--backend pocketbase|sqlite] \
485             [--pocketbase-url URL] [--sqlite-path PATH]"
486        );
487        return Ok(());
488    }
489
490    let run_ref = parsed.positionals().first().cloned().ok_or_else(|| {
491        AppError::Message("del requires a run id (e.g. ploit-malper del <run_id>)".to_string())
492    })?;
493
494    let mut storage = crate::frontend::open_backend(config_mgr, &parsed)?;
495    let run = crate::exploit::planner::Planner::resolve_run(storage.as_mut(), &run_ref)?;
496
497    println!(
498        "[+] Run: {} (target={})",
499        crate::frontend::truncate(&run.stable_id, 16),
500        run.target
501    );
502
503    let confirmed = parsed.has("yes")
504        || prompt_bool_with_default(
505            &format!("Delete run '{}' and its observations?", run.stable_id),
506            false,
507        )?;
508    if !confirmed {
509        println!("[!] Aborted.");
510        return Ok(());
511    }
512
513    storage.delete_scan_run(&run.stable_id)?;
514    println!("[+] Deleted run {} (target={}).", run.stable_id, run.target);
515    Ok(())
516}
517
518fn prompt_text(prompt: &str, default: &str) -> Result<String> {
519    print!("  {} [{}]: ", prompt, default);
520    io::stdout().flush()?;
521    let mut buffer = String::new();
522    io::stdin().read_line(&mut buffer)?;
523    let value = buffer.trim();
524    Ok(if value.is_empty() {
525        default.to_string()
526    } else {
527        value.to_string()
528    })
529}
530
531fn prompt_u16(prompt: &str, default: u16) -> Result<u16> {
532    loop {
533        let value = prompt_text(prompt, &default.to_string())?;
534        if value.is_empty() {
535            return Ok(default);
536        }
537        match value.parse::<u16>() {
538            Ok(parsed) => return Ok(parsed),
539            Err(_) => println!("  Please enter a valid number."),
540        }
541    }
542}
543
544fn prompt_bool(prompt: &str, default: bool) -> Result<bool> {
545    prompt_bool_with_default(prompt, default)
546}
547
548fn prompt_bool_with_default(prompt: &str, default: bool) -> Result<bool> {
549    let suffix = if default { "[Y/n]" } else { "[y/N]" };
550    loop {
551        print!("  {} {}: ", prompt, suffix);
552        io::stdout().flush()?;
553        let mut buffer = String::new();
554        io::stdin().read_line(&mut buffer)?;
555        let value = buffer.trim().to_lowercase();
556        if value.is_empty() {
557            return Ok(default);
558        }
559        match value.as_str() {
560            "y" | "yes" | "true" => return Ok(true),
561            "n" | "no" | "false" => return Ok(false),
562            _ => println!("  Please answer yes or no."),
563        }
564    }
565}
566
567fn prompt_choice(prompt: &str, choices: &[String], default: &str) -> Result<String> {
568    let resolved_default = if choices.iter().any(|c| c == default) {
569        default.to_string()
570    } else {
571        choices.first().cloned().unwrap_or_default()
572    };
573    loop {
574        print!("  {} [{}]: ", prompt, resolved_default);
575        io::stdout().flush()?;
576        let mut buffer = String::new();
577        io::stdin().read_line(&mut buffer)?;
578        let value = buffer.trim();
579        if value.is_empty() {
580            return Ok(resolved_default);
581        }
582        if choices.iter().any(|choice| choice == value) {
583            return Ok(value.to_string());
584        }
585        println!("  Please choose one of: {}", choices.join(", "));
586    }
587}
588
589fn truncate(value: &str, width: usize) -> String {
590    let count = value.chars().count();
591    if count <= width {
592        return value.to_string();
593    }
594    if width <= 1 {
595        return "…".to_string();
596    }
597    let mut truncated = value.chars().take(width - 1).collect::<String>();
598    truncated.push('…');
599    truncated
600}
601
602fn print_banner() {
603    println!("PloitMalper v{}", env!("CARGO_PKG_VERSION"));
604    println!("Vulnerability Post-Processing and Analysis Toolkit");
605    println!();
606    println!("Commands:");
607    println!("  process <file>   Process and deduplicate scan results");
608    println!("  db_setup         Configure the intelligence database (PocketBase/SQLite)");
609    println!("  ingest <folder>  Import Malper scan artifacts into the database");
610    println!("  assets           List assets with lifecycle and service summary");
611    println!("  services         List discovered services across assets");
612    println!("  findings         List vulnerability findings");
613    println!("  history <id>     Show the observation timeline for an asset/service/finding");
614    println!("  runs             List all scan runs");
615    println!("  diff [a] [b]     Compare two scan runs (latest two by default)");
616    println!("  exploit <run_id>  Plan and run exploits from a scan run against a framework");
617    println!("  del <run_id>      Delete a scan run and its observations");
618    println!("  setup            Configure MSF-RPC and NVD API credentials");
619    println!("  reset-config     Reset stored configuration");
620    println!();
621    println!("Frontend commands accept --verbose, --json, and --backend pocketbase|sqlite.");
622}