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