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", ¤t.host)?;
73 let port = prompt_u16("MSF-RPC Port", current.port)?;
74 let username = prompt_text("MSF-RPC Username", ¤t.username)?;
75 let password = prompt_text("MSF-RPC Password", ¤t.password)?;
76 let ssl = prompt_bool("Use SSL?", current.ssl)?;
77 let workspace = prompt_text("Workspace", ¤t.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", ¤t_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, ¤t.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}