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::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", &current.host)?;
92    let port = prompt_u16("MSF-RPC Port", current.port)?;
93    let username = prompt_text("MSF-RPC Username", &current.username)?;
94    let password = prompt_text("MSF-RPC Password", &current.password)?;
95    let ssl = prompt_bool("Use SSL?", current.ssl)?;
96    let workspace = prompt_text("Workspace", &current.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", &current_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, &current.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        // Show a plan (existing vs missing collections) before mutating.
410        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}