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#[derive(Debug, Clone)]
13pub struct ProcessOptions {
14 pub verbose: bool,
15 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#[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
37pub 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 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
209pub 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
221pub 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
279fn 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
324fn 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
339fn 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 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 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}