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