Skip to main content

ploit_malper/
cli.rs

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