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", ¤t.host)?;
95 let port = prompt_u16("MSF-RPC Port", current.port)?;
96 let username = prompt_text("MSF-RPC Username", ¤t.username)?;
97 let password = prompt_text("MSF-RPC Password", ¤t.password)?;
98 let ssl = prompt_bool("Use SSL?", current.ssl)?;
99 let workspace = prompt_text("Workspace", ¤t.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", ¤t_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 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, ¤t.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 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}