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        "-h" | "--help" => {
48            print_banner();
49        }
50        other => {
51            eprintln!("[!] Unknown command: {}", other);
52            print_banner();
53        }
54    }
55
56    Ok(())
57}
58
59fn cmd_setup(config_mgr: &mut ConfigManager) -> Result<()> {
60    println!("PloitMalper Configuration Setup");
61    println!();
62    println!("--- MSF-RPC Configuration ---");
63    println!("Enter your Metasploit RPC server details.");
64    let current = config_mgr.get_msfrpc_config().clone();
65
66    let host = prompt_text("MSF-RPC Host", &current.host)?;
67    let port = prompt_u16("MSF-RPC Port", current.port)?;
68    let username = prompt_text("MSF-RPC Username", &current.username)?;
69    let password = prompt_text("MSF-RPC Password", &current.password)?;
70    let ssl = prompt_bool("Use SSL?", current.ssl)?;
71    let workspace = prompt_text("Workspace", &current.workspace)?;
72
73    config_mgr.config.msfrpc.host = host;
74    config_mgr.config.msfrpc.port = port;
75    config_mgr.config.msfrpc.username = username;
76    config_mgr.config.msfrpc.password = password;
77    config_mgr.config.msfrpc.ssl = ssl;
78    config_mgr.config.msfrpc.workspace = workspace;
79
80    println!();
81    println!("--- NVD API Configuration ---");
82    println!("Enter your NVD API key for CVE enrichment (optional but recommended).");
83    println!("Get one at: https://nvd.nist.gov/developers/request-an-api-key");
84    let current_nvd = config_mgr.get_nvd_config().clone();
85    let nvd_key = prompt_text("NVD API Key", &current_nvd.api_key)?;
86    config_mgr.config.nvd.api_key = nvd_key;
87
88    config_mgr.save()?;
89    println!();
90    println!(
91        "[+] Configuration saved to {}",
92        crate::config::config_file().display()
93    );
94    Ok(())
95}
96
97fn cmd_process(args: &[String], config_mgr: &mut ConfigManager, config_loaded: bool) -> Result<()> {
98    let input_file = match args.first() {
99        Some(path) => path,
100        None => {
101            eprintln!("[!] No input file specified. Use: ploit-malper process <scan.json>");
102            return Ok(());
103        }
104    };
105
106    let input_path = PathBuf::from(input_file);
107    if !input_path.exists() {
108        eprintln!("[!] File not found: {}", input_path.display());
109        return Ok(());
110    }
111
112    println!("[+] Loading scan results from: {}", input_path.display());
113    let raw_json = std::fs::read_to_string(&input_path)?;
114
115    println!("[+] Parsing with Rust engine...");
116    let parsed = parser::parse_vulnmalper_json(&raw_json)?;
117    println!("[+] Found {} raw findings. Deduplicating...", parsed.len());
118
119    let dedup_result = dedup::deduplicate_records(parsed);
120    let mut records = dedup_result.records;
121    let dedup_stats = DedupStats {
122        total: records.len() + dedup_result.removed_count,
123        unique: dedup_result.unique_count,
124        removed: dedup_result.removed_count,
125    };
126
127    println!(
128        "[+] Deduplication complete: {} unique, {} removed",
129        dedup_stats.unique, dedup_stats.removed
130    );
131    println!();
132
133    if config_mgr.is_nvd_configured() {
134        let nvd_cfg = config_mgr.get_nvd_config().clone();
135        let mut nvd_client = NVDClient::new(&nvd_cfg.api_key);
136        println!("[+] Enriching CVEs via NVD API...");
137        let stats: EnrichmentStats = nvd_client.enrich_records(&mut records);
138        println!(
139            "[+] NVD enrichment complete: {} fetched, {} from cache, {} not found",
140            stats.fetched, stats.cache_hits, stats.not_found
141        );
142        println!();
143    }
144
145    let mut all_suggestions = Vec::<ModuleSuggestion>::new();
146    let mut seen_suggestions = HashSet::<(String, String)>::new();
147
148    println!("[+] Analyzing service banners and titles for module suggestions...");
149    for record in &records {
150        if let Some(service) = record.service.as_deref() {
151            for suggestion in matcher::suggest_modules(service) {
152                push_unique_suggestion(&mut all_suggestions, &mut seen_suggestions, suggestion);
153            }
154        }
155        if !record.title.is_empty() {
156            for suggestion in matcher::suggest_from_title(&record.title, "") {
157                push_unique_suggestion(&mut all_suggestions, &mut seen_suggestions, suggestion);
158            }
159        }
160    }
161
162    if all_suggestions.is_empty() {
163        println!("[?] No module suggestions for detected services.");
164    }
165
166    println!();
167    println!(
168        "{}",
169        report::render_console_report(&records, &all_suggestions, &[])
170    );
171    println!();
172
173    let mut msf_connected = false;
174    let mut msf_client = None;
175    if !config_loaded || !config_mgr.is_configured() {
176        println!("[?] No MSF-RPC configuration found.");
177        if prompt_bool_with_default("Configure MSF-RPC and NVD API now?", true)? {
178            cmd_setup(config_mgr)?;
179        } else {
180            println!("[?] Continuing without MSF-RPC integration.");
181            println!();
182        }
183    }
184
185    if config_mgr.is_configured() {
186        let msf_cfg = config_mgr.get_msfrpc_config().clone();
187        println!();
188        println!("[+] Connecting to MSF-RPC for workspace verification...");
189        let mut client = msfrpc::MsfRpcClient::new(
190            &msf_cfg.host,
191            msf_cfg.port,
192            &msf_cfg.username,
193            &msf_cfg.password,
194            msf_cfg.ssl,
195        );
196
197        let login_result = client.login()?;
198        if login_result.success {
199            msf_connected = true;
200            println!(
201                "[+] Authenticated to MSF-RPC at {}:{}",
202                msf_cfg.host, msf_cfg.port
203            );
204
205            let ws_result = client.get_workspaces()?;
206            if !ws_result.workspaces.is_empty() {
207                println!();
208                println!("MSF Workspaces");
209                println!("{:<24} {:<16} {:>8}", "Name", "Scope", "Hosts");
210                println!("{}", "-".repeat(52));
211                for workspace in &ws_result.workspaces {
212                    println!(
213                        "{:<24} {:<16} {:>8}",
214                        truncate(&workspace.name, 24),
215                        truncate(&workspace.scope, 16),
216                        workspace.host_count
217                    );
218                }
219            }
220
221            for record in records.iter().take(5) {
222                if let Some(target) = record.target.as_str().split_whitespace().next() {
223                    let check_result =
224                        client.check_host_exists(target, Some(&msf_cfg.workspace))?;
225                    if check_result.exists {
226                        println!(
227                            "[?] Host {} already exists in workspace '{}'",
228                            target, msf_cfg.workspace
229                        );
230                    }
231                }
232            }
233        } else {
234            println!(
235                "[!] Remote MSF-RPC unreachable. Switched entirely to offline matching matrix."
236            );
237            println!("    Error: {}", login_result.error);
238        }
239
240        msf_client = Some(client);
241    }
242
243    let mut recipes: Vec<PayloadRecipe> = Vec::new();
244    if prompt_bool_with_default("\nGenerate msfvenom payload recipes?", false)? {
245        println!();
246        println!("Payload Recipe Builder");
247        let platforms = recipe::get_available_platforms();
248        println!("Available platforms: {}", platforms.join(", "));
249        let platform = prompt_choice("Platform", &platforms, "windows")?;
250        let arches = recipe::get_available_arches(&platform);
251        println!("Available architectures: {}", arches.join(", "));
252        let arch = prompt_choice("Architecture", &arches, "x64")?;
253        let lhost = prompt_text("LHOST", "10.0.0.1")?;
254        let lport = prompt_u16("LPORT", 4444)?;
255        let output = prompt_text("Output path", "/tmp/payload")?;
256
257        let recipe = recipe::build_recipe(&platform, &arch, &lhost, lport, &output, None, None);
258        println!();
259        println!("Generated recipe:");
260        println!("{}", recipe.command);
261        recipes.push(recipe);
262    }
263
264    if prompt_bool_with_default("\nWrite findings to Markdown report (report.md)?", true)? {
265        let output_path = report::generate_markdown_report(
266            &records,
267            &all_suggestions,
268            &recipes,
269            &dedup_stats,
270            "report.md",
271        )?;
272        println!();
273        println!("[+] Report written to: {}", output_path.display());
274    }
275
276    if msf_connected {
277        if let Some(mut client) = msf_client {
278            client.logout();
279        }
280    }
281
282    config_mgr.config.last_scan_file = Some(input_path.to_string_lossy().to_string());
283    config_mgr.save()?;
284    Ok(())
285}
286
287fn cmd_share(args: &[String]) -> Result<()> {
288    let mut directory = PathBuf::from(".");
289    let mut port = 8888u16;
290
291    let mut index = 0usize;
292    while index < args.len() {
293        match args[index].as_str() {
294            "--directory" | "-d" => {
295                if let Some(value) = args.get(index + 1) {
296                    directory = PathBuf::from(value);
297                    index += 2;
298                    continue;
299                }
300            }
301            "--port" | "-p" => {
302                if let Some(value) = args.get(index + 1) {
303                    port = value
304                        .parse::<u16>()
305                        .map_err(|_| AppError::Message(format!("Invalid port value: {}", value)))?;
306                    index += 2;
307                    continue;
308                }
309            }
310            _ => {}
311        }
312        index += 1;
313    }
314
315    println!("[+] Starting file server in: {}", directory.display());
316    println!("[+] Port: {}", port);
317    println!();
318
319    let (server, actual_port) = share::start_file_server(directory, port)?;
320    println!(
321        "File server running at http://0.0.0.0:{} (press Ctrl+C to stop)",
322        actual_port
323    );
324
325    let _server = server;
326    loop {
327        thread::sleep(Duration::from_secs(1));
328    }
329}
330
331fn cmd_reset_config(config_mgr: &mut ConfigManager) -> Result<()> {
332    println!("[?] Resetting all stored configuration...");
333    config_mgr.reset()?;
334    println!("[+] Configuration reset. Run ploit-malper to set up credentials.");
335    Ok(())
336}
337
338fn push_unique_suggestion(
339    suggestions: &mut Vec<ModuleSuggestion>,
340    seen: &mut HashSet<(String, String)>,
341    suggestion: ModuleSuggestion,
342) {
343    let key = (
344        suggestion.service_banner.clone(),
345        suggestion.suggested_module.clone(),
346    );
347    if seen.insert(key) {
348        suggestions.push(suggestion);
349    }
350}
351
352fn prompt_text(prompt: &str, default: &str) -> Result<String> {
353    print!("  {} [{}]: ", prompt, default);
354    io::stdout().flush()?;
355    let mut buffer = String::new();
356    io::stdin().read_line(&mut buffer)?;
357    let value = buffer.trim();
358    Ok(if value.is_empty() {
359        default.to_string()
360    } else {
361        value.to_string()
362    })
363}
364
365fn prompt_u16(prompt: &str, default: u16) -> Result<u16> {
366    loop {
367        let value = prompt_text(prompt, &default.to_string())?;
368        if value.is_empty() {
369            return Ok(default);
370        }
371        match value.parse::<u16>() {
372            Ok(parsed) => return Ok(parsed),
373            Err(_) => println!("  Please enter a valid number."),
374        }
375    }
376}
377
378fn prompt_bool(prompt: &str, default: bool) -> Result<bool> {
379    prompt_bool_with_default(prompt, default)
380}
381
382fn prompt_bool_with_default(prompt: &str, default: bool) -> Result<bool> {
383    let suffix = if default { "[Y/n]" } else { "[y/N]" };
384    loop {
385        print!("  {} {}: ", prompt, suffix);
386        io::stdout().flush()?;
387        let mut buffer = String::new();
388        io::stdin().read_line(&mut buffer)?;
389        let value = buffer.trim().to_lowercase();
390        if value.is_empty() {
391            return Ok(default);
392        }
393        match value.as_str() {
394            "y" | "yes" | "true" => return Ok(true),
395            "n" | "no" | "false" => return Ok(false),
396            _ => println!("  Please answer yes or no."),
397        }
398    }
399}
400
401fn prompt_choice(prompt: &str, choices: &[String], default: &str) -> Result<String> {
402    let resolved_default = if choices.iter().any(|c| c == default) {
403        default.to_string()
404    } else {
405        choices.first().cloned().unwrap_or_default()
406    };
407    loop {
408        print!("  {} [{}]: ", prompt, resolved_default);
409        io::stdout().flush()?;
410        let mut buffer = String::new();
411        io::stdin().read_line(&mut buffer)?;
412        let value = buffer.trim();
413        if value.is_empty() {
414            return Ok(resolved_default);
415        }
416        if choices.iter().any(|choice| choice == value) {
417            return Ok(value.to_string());
418        }
419        println!("  Please choose one of: {}", choices.join(", "));
420    }
421}
422
423fn truncate(value: &str, width: usize) -> String {
424    let count = value.chars().count();
425    if count <= width {
426        return value.to_string();
427    }
428    if width <= 1 {
429        return "…".to_string();
430    }
431    let mut truncated = value.chars().take(width - 1).collect::<String>();
432    truncated.push('…');
433    truncated
434}
435
436fn print_banner() {
437    println!("PloitMalper v{}", env!("CARGO_PKG_VERSION"));
438    println!("Vulnerability Post-Processing and Analysis Toolkit");
439    println!();
440    println!("Commands:");
441    println!("  process <file>   Process and deduplicate scan results");
442    println!("  share            Start temporary file server");
443    println!("  setup            Configure MSF-RPC and NVD API credentials");
444    println!("  reset-config     Reset stored configuration");
445}