Skip to main content

ploit_malper/
process.rs

1use std::collections::{BTreeSet, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::config::ConfigManager;
5use crate::engine::{catalog::LiveModuleCatalog, dedup, matcher, msfrpc, parser};
6use crate::error::{AppError, Result};
7use crate::models::{DedupStats, InjectableEndpoint, ModuleSuggestion, ScanRecord};
8use crate::nvd::{EnrichmentStats, NVDClient};
9use crate::report;
10
11/// Options controlling the shared `process` pipeline.
12#[derive(Debug, Clone)]
13pub struct ProcessOptions {
14    pub verbose: bool,
15    /// Render the full console report to stdout.
16    pub print_console: bool,
17}
18
19impl Default for ProcessOptions {
20    fn default() -> Self {
21        Self {
22            verbose: false,
23            print_console: true,
24        }
25    }
26}
27
28/// The outcome of running the shared process pipeline over one scan JSON.
29#[derive(Debug)]
30pub struct ProcessResult {
31    pub records: Vec<ScanRecord>,
32    pub injectable: Vec<InjectableEndpoint>,
33    pub suggestions: Vec<ModuleSuggestion>,
34    pub dedup_stats: DedupStats,
35}
36
37/// Shared pipeline: parse -> normalize -> dedup -> NVD enrich -> suggest.
38/// Used by both the standalone `process` command and `ingest --process` so
39/// the two never drift apart.
40pub fn process_scan_file(
41    input_path: &Path,
42    config_mgr: &mut ConfigManager,
43    opts: &ProcessOptions,
44) -> Result<ProcessResult> {
45    if opts.verbose {
46        println!("[dbg] processing {}", input_path.display());
47    }
48    println!("[+] Loading scan results from: {}", input_path.display());
49    let raw_json = std::fs::read_to_string(input_path)?;
50
51    println!("[+] Parsing with Rust engine...");
52    let mut parsed = parser::parse_vulnmalper_scan(&raw_json)?;
53    let injectable = parsed.injectable_endpoints.clone();
54    let raw_count = parsed.records.len();
55
56    println!(
57        "[+] Found {} raw findings ({} injectable endpoint{} flagged). Normalizing...",
58        raw_count,
59        injectable.len(),
60        if injectable.len() == 1 { "" } else { "s" }
61    );
62
63    // Extract CVEs from every field before deduplication.
64    for record in &mut parsed.records {
65        crate::cve::extract_into_record(record);
66    }
67    let discovered_cves: std::collections::BTreeSet<String> = parsed
68        .records
69        .iter()
70        .flat_map(|record| record.cves.iter().cloned())
71        .collect();
72    println!(
73        "[+] Discovered {} CVE id{}.",
74        discovered_cves.len(),
75        if discovered_cves.len() == 1 { "" } else { "s" }
76    );
77
78    let dedup_result = dedup::deduplicate_records(parsed.records);
79    let mut records = dedup_result.records;
80    let dedup_stats = DedupStats {
81        total: raw_count,
82        unique: dedup_result.unique_count,
83        removed: dedup_result.removed_count,
84    };
85
86    println!(
87        "[+] Normalization + deduplication complete: {} unique, {} merged",
88        dedup_stats.unique, dedup_stats.removed
89    );
90    println!();
91
92    if config_mgr.is_nvd_configured() {
93        let nvd_cfg = config_mgr.get_nvd_config().clone();
94        let mut nvd_client = NVDClient::new(&nvd_cfg.api_key);
95        println!("[+] Enriching discovered CVEs via NVD API...");
96        let stats: EnrichmentStats = nvd_client.enrich_records(&mut records);
97        println!(
98            "[+] NVD enrichment complete: {} fetched, {} from cache, {} not found",
99            stats.fetched, stats.cache_hits, stats.not_found
100        );
101        println!();
102    }
103
104    let live_catalog = connect_live_catalog(config_mgr, opts.verbose)?;
105
106    let mut all_suggestions = Vec::<ModuleSuggestion>::new();
107    let mut seen_suggestions = HashSet::<(String, String)>::new();
108    let mut dropped_offline = BTreeSet::<String>::new();
109
110    println!("[+] Analyzing service banners, technologies and CVEs for module suggestions...");
111    for record in &records {
112        if let Some(service) = record.service.as_deref() {
113            if opts.verbose {
114                println!("[dbg] searching modules for service '{}'", service);
115            }
116            let offline = matcher::suggest_modules(service);
117            let live = match &live_catalog {
118                Some(catalog) => catalog.suggest_for_banner(service),
119                None => Vec::new(),
120            };
121            note_dropped_offline(&live_catalog, &offline, &live, &mut dropped_offline);
122            for suggestion in live_or_offline(&live_catalog, offline, live) {
123                push_unique_suggestion(&mut all_suggestions, &mut seen_suggestions, suggestion);
124            }
125        }
126        if !record.title.is_empty() {
127            if opts.verbose {
128                println!("[dbg] matching modules for title '{}'", record.title);
129            }
130            let offline = matcher::suggest_from_title(&record.title, &record.detail);
131            let live = match &live_catalog {
132                Some(catalog) => catalog.suggest_for_title(&record.title, &record.detail),
133                None => Vec::new(),
134            };
135            note_dropped_offline(&live_catalog, &offline, &live, &mut dropped_offline);
136            for suggestion in live_or_offline(&live_catalog, offline, live) {
137                push_unique_suggestion(&mut all_suggestions, &mut seen_suggestions, suggestion);
138            }
139        }
140        for cve in &record.cves {
141            if opts.verbose {
142                println!("[dbg] searching modules for {}", cve);
143            }
144            let offline: Vec<ModuleSuggestion> = crate::msf::modules_for_cve(cve)
145                .into_iter()
146                .map(|module| suggestion_for_module(cve, &module))
147                .collect();
148            let live: Vec<ModuleSuggestion> = match &live_catalog {
149                Some(catalog) => catalog
150                    .modules_for_cve(cve)
151                    .into_iter()
152                    .map(|module| suggestion_for_module(cve, &module))
153                    .collect(),
154                None => Vec::new(),
155            };
156            note_dropped_offline(&live_catalog, &offline, &live, &mut dropped_offline);
157            for suggestion in live_or_offline(&live_catalog, offline, live) {
158                push_unique_suggestion(&mut all_suggestions, &mut seen_suggestions, suggestion);
159            }
160        }
161    }
162
163    if !dropped_offline.is_empty() {
164        let dropped: Vec<&str> = dropped_offline.iter().map(String::as_str).collect();
165        println!(
166            "[!] Skipping {} module(s) not present on the connected MSF instance: {}",
167            dropped.len(),
168            dropped.join(", ")
169        );
170        println!();
171    }
172
173    if all_suggestions.is_empty() {
174        println!("[?] No module suggestions for detected services or CVEs.");
175    } else {
176        println!("[+] {} module suggestion(s) found:", all_suggestions.len());
177        for suggestion in &all_suggestions {
178            println!(
179                "    {} (confidence: {})",
180                suggestion.suggested_module, suggestion.confidence
181            );
182        }
183    }
184
185    println!();
186
187    if opts.print_console {
188        println!(
189            "{}",
190            report::render_console_report(
191                &records,
192                &injectable,
193                &all_suggestions,
194                &[],
195                &dedup_stats
196            )
197        );
198        println!();
199    }
200
201    Ok(ProcessResult {
202        records,
203        injectable,
204        suggestions: all_suggestions,
205        dedup_stats,
206    })
207}
208
209/// Write the PloitMalper markdown report for a processed result.
210pub fn write_markdown_report(result: &ProcessResult, path: &Path) -> Result<PathBuf> {
211    report::generate_markdown_report(
212        &result.records,
213        &result.injectable,
214        &result.suggestions,
215        &[],
216        &result.dedup_stats,
217        path.to_str().unwrap_or("report.md"),
218    )
219}
220
221/// Locate every recognised VulnMalper JSON under `folder` and process it,
222/// writing the PloitMalper markdown report next to each JSON. Returns the
223/// number of reports written.
224pub fn process_folder(
225    folder: &Path,
226    config_mgr: &mut ConfigManager,
227    verbose: bool,
228) -> Result<usize> {
229    let scan = crate::ingest::scanner::scan_folder(folder, verbose)?;
230    let jsons: Vec<_> = scan
231        .artifacts
232        .iter()
233        .filter(|a| a.kind == crate::ingest::scanner::ArtifactKind::VulnMalperJson)
234        .collect();
235
236    if jsons.is_empty() {
237        return Err(AppError::Message(format!(
238            "no VulnMalper JSON files found in '{}' to process",
239            folder.display()
240        )));
241    }
242
243    let opts = ProcessOptions {
244        verbose,
245        print_console: false,
246    };
247    let mut written = 0usize;
248    for artifact in &jsons {
249        let input_path = PathBuf::from(&artifact.path);
250        let report_path = report::derive_report_path(&input_path);
251        println!();
252        println!(
253            "[+] Processing {} -> {}",
254            input_path.display(),
255            report_path.display()
256        );
257        let result = process_scan_file(&input_path, config_mgr, &opts)?;
258        write_markdown_report(&result, &report_path)?;
259        println!("[+] Report written to: {}", report_path.display());
260        written += 1;
261    }
262    Ok(written)
263}
264
265fn push_unique_suggestion(
266    suggestions: &mut Vec<ModuleSuggestion>,
267    seen: &mut HashSet<(String, String)>,
268    suggestion: ModuleSuggestion,
269) {
270    let key = (
271        suggestion.service_banner.clone(),
272        suggestion.suggested_module.clone(),
273    );
274    if seen.insert(key) {
275        suggestions.push(suggestion);
276    }
277}
278
279/// Connect to the configured MSF-RPC instance and load the live module
280/// catalog. Returns `None` (and falls back to the offline catalogs) whenever
281/// the instance is unreachable or its module listing cannot be fetched, so the
282/// shared pipeline keeps working without a live Metasploit.
283fn connect_live_catalog(
284    config_mgr: &ConfigManager,
285    verbose: bool,
286) -> Result<Option<LiveModuleCatalog>> {
287    if !config_mgr.is_configured() {
288        if verbose {
289            println!("[dbg] MSF-RPC not configured; using offline module catalogs.");
290        }
291        return Ok(None);
292    }
293    let cfg = config_mgr.get_msfrpc_config().clone();
294    println!("[+] Connecting to MSF-RPC for live module lookup...");
295    let mut client =
296        msfrpc::MsfRpcClient::new(&cfg.host, cfg.port, &cfg.username, &cfg.password, cfg.ssl);
297    let login = client.login()?;
298    if !login.success {
299        println!(
300            "[!] MSF-RPC unreachable at {}:{}; using offline module catalogs. ({})",
301            cfg.host, cfg.port, login.error
302        );
303        return Ok(None);
304    }
305    let modules = match client.list_modules() {
306        Ok(modules) => modules,
307        Err(error) => {
308            println!(
309                "[!] Could not list modules from MSF-RPC; using offline module catalogs. ({error})"
310            );
311            client.logout();
312            return Ok(None);
313        }
314    };
315    let catalog = LiveModuleCatalog::from_modules(modules);
316    println!(
317        "[+] Loaded {} module(s) from the connected instance; suggestions verified live.",
318        catalog.len()
319    );
320    client.logout();
321    Ok(Some(catalog))
322}
323
324/// When a live catalog is active, suggestions must come from the live module
325/// list (which may be empty — that is the point: no offline guesses). When it
326/// is not active, the offline suggestions are used as before.
327fn live_or_offline(
328    live_catalog: &Option<LiveModuleCatalog>,
329    offline: Vec<ModuleSuggestion>,
330    live: Vec<ModuleSuggestion>,
331) -> Vec<ModuleSuggestion> {
332    if live_catalog.is_some() {
333        live
334    } else {
335        offline
336    }
337}
338
339/// Record which offline-catalog modules were dropped because they do not exist
340/// on the connected instance, so the operator can see the live verification in
341/// action instead of silently losing suggestions.
342fn note_dropped_offline(
343    live_catalog: &Option<LiveModuleCatalog>,
344    offline: &[ModuleSuggestion],
345    live: &[ModuleSuggestion],
346    dropped: &mut BTreeSet<String>,
347) {
348    if live_catalog.is_none() {
349        return;
350    }
351    let live_names: HashSet<&str> = live.iter().map(|s| s.suggested_module.as_str()).collect();
352    for suggestion in offline {
353        if !live_names.contains(suggestion.suggested_module.as_str()) {
354            dropped.insert(suggestion.suggested_module.clone());
355        }
356    }
357}
358
359fn suggestion_for_module(cve: &str, module: &crate::models::MSFModule) -> ModuleSuggestion {
360    ModuleSuggestion {
361        service_banner: format!("cve:{}", cve),
362        suggested_module: module.name.clone(),
363        confidence: if module.rank == "excellent" || module.rank == "great" {
364            "high".to_string()
365        } else {
366            "medium".to_string()
367        },
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    fn suggestion(module: &str) -> ModuleSuggestion {
376        ModuleSuggestion {
377            service_banner: "banner".to_string(),
378            suggested_module: module.to_string(),
379            confidence: "high".to_string(),
380        }
381    }
382
383    #[test]
384    fn live_catalog_suppresses_offline_guesses() {
385        // nginx_version exists only in the offline catalog; with a live catalog
386        // present the offline suggestion must be dropped.
387        let offline = vec![suggestion("auxiliary/scanner/http/nginx_version")];
388        let live: Vec<ModuleSuggestion> = Vec::new();
389        let mut dropped = BTreeSet::new();
390        note_dropped_offline(
391            &Some(LiveModuleCatalog::from_modules(Vec::new())),
392            &offline,
393            &live,
394            &mut dropped,
395        );
396        assert!(dropped.contains("auxiliary/scanner/http/nginx_version"));
397    }
398
399    #[test]
400    fn offline_fallback_keeps_offline_suggestions() {
401        // Without a live catalog the offline suggestions flow through.
402        let offline = vec![suggestion("auxiliary/scanner/http/robots_txt")];
403        let live: Vec<ModuleSuggestion> = Vec::new();
404        let chosen = live_or_offline(&None, offline.clone(), live);
405        assert_eq!(chosen.len(), 1);
406        assert_eq!(
407            chosen[0].suggested_module,
408            "auxiliary/scanner/http/robots_txt"
409        );
410    }
411
412    #[test]
413    fn live_catalog_replaces_offline_suggestions() {
414        let offline = vec![suggestion("auxiliary/scanner/http/nginx_version")];
415        let live = vec![suggestion("auxiliary/scanner/http/robots_txt")];
416        let chosen = live_or_offline(
417            &Some(LiveModuleCatalog::from_modules(Vec::new())),
418            offline,
419            live,
420        );
421        assert_eq!(chosen.len(), 1);
422        assert_eq!(
423            chosen[0].suggested_module,
424            "auxiliary/scanner/http/robots_txt"
425        );
426    }
427}