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 = msfrpc::MsfRpcClient::new(
296 &cfg.host,
297 cfg.port,
298 &cfg.username,
299 &cfg.password,
300 cfg.ssl,
301 );
302 let login = client.login()?;
303 if !login.success {
304 println!(
305 "[!] MSF-RPC unreachable at {}:{}; using offline module catalogs. ({})",
306 cfg.host, cfg.port, login.error
307 );
308 return Ok(None);
309 }
310 let modules = match client.list_modules() {
311 Ok(modules) => modules,
312 Err(error) => {
313 println!(
314 "[!] Could not list modules from MSF-RPC; using offline module catalogs. ({error})"
315 );
316 client.logout();
317 return Ok(None);
318 }
319 };
320 let catalog = LiveModuleCatalog::from_modules(modules);
321 println!(
322 "[+] Loaded {} module(s) from the connected instance; suggestions verified live.",
323 catalog.len()
324 );
325 client.logout();
326 Ok(Some(catalog))
327}
328
329fn live_or_offline(
333 live_catalog: &Option<LiveModuleCatalog>,
334 offline: Vec<ModuleSuggestion>,
335 live: Vec<ModuleSuggestion>,
336) -> Vec<ModuleSuggestion> {
337 if live_catalog.is_some() {
338 live
339 } else {
340 offline
341 }
342}
343
344fn note_dropped_offline(
348 live_catalog: &Option<LiveModuleCatalog>,
349 offline: &[ModuleSuggestion],
350 live: &[ModuleSuggestion],
351 dropped: &mut BTreeSet<String>,
352) {
353 if live_catalog.is_none() {
354 return;
355 }
356 let live_names: HashSet<&str> = live
357 .iter()
358 .map(|s| s.suggested_module.as_str())
359 .collect();
360 for suggestion in offline {
361 if !live_names.contains(suggestion.suggested_module.as_str()) {
362 dropped.insert(suggestion.suggested_module.clone());
363 }
364 }
365}
366
367fn suggestion_for_module(cve: &str, module: &crate::models::MSFModule) -> ModuleSuggestion {
368 ModuleSuggestion {
369 service_banner: format!("cve:{}", cve),
370 suggested_module: module.name.clone(),
371 confidence: if module.rank == "excellent" || module.rank == "great" {
372 "high".to_string()
373 } else {
374 "medium".to_string()
375 },
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 fn suggestion(module: &str) -> ModuleSuggestion {
384 ModuleSuggestion {
385 service_banner: "banner".to_string(),
386 suggested_module: module.to_string(),
387 confidence: "high".to_string(),
388 }
389 }
390
391 #[test]
392 fn live_catalog_suppresses_offline_guesses() {
393 let offline = vec![suggestion("auxiliary/scanner/http/nginx_version")];
396 let live: Vec<ModuleSuggestion> = Vec::new();
397 let mut dropped = BTreeSet::new();
398 note_dropped_offline(
399 &Some(LiveModuleCatalog::from_modules(Vec::new())),
400 &offline,
401 &live,
402 &mut dropped,
403 );
404 assert!(dropped.contains("auxiliary/scanner/http/nginx_version"));
405 }
406
407 #[test]
408 fn offline_fallback_keeps_offline_suggestions() {
409 let offline = vec![suggestion("auxiliary/scanner/http/robots_txt")];
411 let live: Vec<ModuleSuggestion> = Vec::new();
412 let chosen = live_or_offline(&None, offline.clone(), live);
413 assert_eq!(chosen.len(), 1);
414 assert_eq!(chosen[0].suggested_module, "auxiliary/scanner/http/robots_txt");
415 }
416
417 #[test]
418 fn live_catalog_replaces_offline_suggestions() {
419 let offline = vec![suggestion("auxiliary/scanner/http/nginx_version")];
420 let live = vec![suggestion("auxiliary/scanner/http/robots_txt")];
421 let chosen = live_or_offline(&Some(LiveModuleCatalog::from_modules(Vec::new())), offline, live);
422 assert_eq!(chosen.len(), 1);
423 assert_eq!(chosen[0].suggested_module, "auxiliary/scanner/http/robots_txt");
424 }
425}