Skip to main content

ploit_malper/
process.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::config::ConfigManager;
5use crate::engine::{dedup, matcher, 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 mut all_suggestions = Vec::<ModuleSuggestion>::new();
105    let mut seen_suggestions = HashSet::<(String, String)>::new();
106
107    println!("[+] Analyzing service banners, technologies and CVEs for module suggestions...");
108    for record in &records {
109        if let Some(service) = record.service.as_deref() {
110            if opts.verbose {
111                println!("[dbg] searching modules for service '{}'", service);
112            }
113            for suggestion in matcher::suggest_modules(service) {
114                push_unique_suggestion(&mut all_suggestions, &mut seen_suggestions, suggestion);
115            }
116        }
117        if !record.title.is_empty() {
118            if opts.verbose {
119                println!("[dbg] matching modules for title '{}'", record.title);
120            }
121            for suggestion in matcher::suggest_from_title(&record.title, &record.detail) {
122                push_unique_suggestion(&mut all_suggestions, &mut seen_suggestions, suggestion);
123            }
124        }
125        for cve in &record.cves {
126            if opts.verbose {
127                println!("[dbg] searching modules for {}", cve);
128            }
129            for module in crate::msf::modules_for_cve(cve) {
130                let suggestion = ModuleSuggestion {
131                    service_banner: format!("cve:{}", cve),
132                    suggested_module: module.name.clone(),
133                    confidence: if module.rank == "excellent" || module.rank == "great" {
134                        "high".to_string()
135                    } else {
136                        "medium".to_string()
137                    },
138                };
139                push_unique_suggestion(&mut all_suggestions, &mut seen_suggestions, suggestion);
140            }
141        }
142    }
143
144    if all_suggestions.is_empty() {
145        println!("[?] No module suggestions for detected services or CVEs.");
146    } else {
147        println!("[+] {} module suggestion(s) found:", all_suggestions.len());
148        for suggestion in &all_suggestions {
149            println!(
150                "    {} (confidence: {})",
151                suggestion.suggested_module, suggestion.confidence
152            );
153        }
154    }
155
156    println!();
157
158    if opts.print_console {
159        println!(
160            "{}",
161            report::render_console_report(
162                &records,
163                &injectable,
164                &all_suggestions,
165                &[],
166                &dedup_stats
167            )
168        );
169        println!();
170    }
171
172    Ok(ProcessResult {
173        records,
174        injectable,
175        suggestions: all_suggestions,
176        dedup_stats,
177    })
178}
179
180/// Write the PloitMalper markdown report for a processed result.
181pub fn write_markdown_report(result: &ProcessResult, path: &Path) -> Result<PathBuf> {
182    report::generate_markdown_report(
183        &result.records,
184        &result.injectable,
185        &result.suggestions,
186        &[],
187        &result.dedup_stats,
188        path.to_str().unwrap_or("report.md"),
189    )
190}
191
192/// Locate every recognised VulnMalper JSON under `folder` and process it,
193/// writing the PloitMalper markdown report next to each JSON. Returns the
194/// number of reports written.
195pub fn process_folder(
196    folder: &Path,
197    config_mgr: &mut ConfigManager,
198    verbose: bool,
199) -> Result<usize> {
200    let scan = crate::ingest::scanner::scan_folder(folder, verbose)?;
201    let jsons: Vec<_> = scan
202        .artifacts
203        .iter()
204        .filter(|a| a.kind == crate::ingest::scanner::ArtifactKind::VulnMalperJson)
205        .collect();
206
207    if jsons.is_empty() {
208        return Err(AppError::Message(format!(
209            "no VulnMalper JSON files found in '{}' to process",
210            folder.display()
211        )));
212    }
213
214    let opts = ProcessOptions {
215        verbose,
216        print_console: false,
217    };
218    let mut written = 0usize;
219    for artifact in &jsons {
220        let input_path = PathBuf::from(&artifact.path);
221        let report_path = report::derive_report_path(&input_path);
222        println!();
223        println!(
224            "[+] Processing {} -> {}",
225            input_path.display(),
226            report_path.display()
227        );
228        let result = process_scan_file(&input_path, config_mgr, &opts)?;
229        write_markdown_report(&result, &report_path)?;
230        println!("[+] Report written to: {}", report_path.display());
231        written += 1;
232    }
233    Ok(written)
234}
235
236fn push_unique_suggestion(
237    suggestions: &mut Vec<ModuleSuggestion>,
238    seen: &mut HashSet<(String, String)>,
239    suggestion: ModuleSuggestion,
240) {
241    let key = (
242        suggestion.service_banner.clone(),
243        suggestion.suggested_module.clone(),
244    );
245    if seen.insert(key) {
246        suggestions.push(suggestion);
247    }
248}