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