1use std::collections::BTreeMap;
2use std::fs;
3use std::path::PathBuf;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::error::Result;
7use crate::models::{DedupStats, ModuleSuggestion, PayloadRecipe, ScanRecord};
8
9fn truncate(value: &str, limit: usize) -> String {
10 let count = value.chars().count();
11 if count <= limit {
12 value.to_string()
13 } else if limit <= 1 {
14 "…".to_string()
15 } else {
16 let mut truncated = value.chars().take(limit - 1).collect::<String>();
17 truncated.push('…');
18 truncated
19 }
20}
21
22fn pad(value: &str, width: usize) -> String {
23 let visible = value.chars().count();
24 if visible >= width {
25 value.to_string()
26 } else {
27 format!("{}{}", value, " ".repeat(width - visible))
28 }
29}
30
31fn ascii_table(title: &str, headers: &[&str], rows: &[Vec<String>]) -> String {
32 let mut widths = headers
33 .iter()
34 .map(|h| h.chars().count())
35 .collect::<Vec<_>>();
36 for row in rows {
37 for (idx, cell) in row.iter().enumerate() {
38 widths[idx] = widths[idx].max(cell.chars().count());
39 }
40 }
41
42 let separator = {
43 let mut s = String::from("+");
44 for width in &widths {
45 s.push_str(&"-".repeat(*width + 2));
46 s.push('+');
47 }
48 s
49 };
50
51 let mut out = String::new();
52 out.push_str(title);
53 out.push('\n');
54 out.push_str(&separator);
55 out.push('\n');
56 out.push('|');
57 for (idx, header) in headers.iter().enumerate() {
58 out.push(' ');
59 out.push_str(&pad(header, widths[idx]));
60 out.push(' ');
61 out.push('|');
62 }
63 out.push('\n');
64 out.push_str(&separator);
65 out.push('\n');
66 for row in rows {
67 out.push('|');
68 for (idx, cell) in row.iter().enumerate() {
69 out.push(' ');
70 out.push_str(&pad(cell, widths[idx]));
71 out.push(' ');
72 out.push('|');
73 }
74 out.push('\n');
75 }
76 out.push_str(&separator);
77 out
78}
79
80fn cvss_label(score: Option<f32>) -> String {
81 match score {
82 None => "—".to_string(),
83 Some(score) => format!("{score:.1}"),
84 }
85}
86
87pub fn render_findings_table(records: &[ScanRecord]) -> String {
88 let has_nvd = records.iter().any(|record| record.nvd_cvss_v3.is_some());
89 let mut rows = Vec::new();
90
91 for (idx, record) in records.iter().enumerate() {
92 let mut row = vec![
93 (idx + 1).to_string(),
94 truncate(&record.target, 18),
95 truncate(&record.tool, 10),
96 truncate(&record.title, 35),
97 record.severity.to_uppercase(),
98 record
99 .port
100 .map(|p| p.to_string())
101 .unwrap_or_else(|| "—".to_string()),
102 record.cve.clone().unwrap_or_else(|| "—".to_string()),
103 ];
104
105 if has_nvd {
106 row.push(cvss_label(record.nvd_cvss_v3));
107 }
108
109 rows.push(row);
110 }
111
112 let mut headers = vec!["#", "Target", "Tool", "Title", "Severity", "Port", "CVE"];
113 if has_nvd {
114 headers.push("CVSS v3");
115 }
116
117 ascii_table("Deduplicated Vulnerability Findings", &headers, &rows)
118}
119
120pub fn render_module_table(suggestions: &[ModuleSuggestion]) -> String {
121 let rows = suggestions
122 .iter()
123 .map(|sug| {
124 vec![
125 truncate(&sug.service_banner, 24),
126 truncate(&sug.suggested_module, 54),
127 sug.confidence.to_uppercase(),
128 ]
129 })
130 .collect::<Vec<_>>();
131
132 ascii_table(
133 "Metasploit Module Suggestions",
134 &["Service Banner", "Suggested Module", "Confidence"],
135 &rows,
136 )
137}
138
139pub fn generate_markdown_report(
140 records: &[ScanRecord],
141 suggestions: &[ModuleSuggestion],
142 recipes: &[PayloadRecipe],
143 dedup_stats: &DedupStats,
144 output_path: &str,
145) -> Result<PathBuf> {
146 let mut lines = Vec::new();
147 let has_nvd = records.iter().any(|record| record.nvd_cvss_v3.is_some());
148
149 lines.push("# PloitMalper - Vulnerability Analysis Report".to_string());
150 lines.push(String::new());
151 lines.push(format!(
152 "**Generated:** {}",
153 SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .map(|d| d.as_secs().to_string())
156 .unwrap_or_else(|_| "unknown".to_string())
157 ));
158 lines.push("**Tool Version:** 0.1.0".to_string());
159 lines.push(String::new());
160
161 lines.push("## Executive Summary".to_string());
162 lines.push(String::new());
163 lines.push(format!(
164 "- **Total records processed:** {}",
165 dedup_stats.total
166 ));
167 lines.push(format!("- **Unique findings:** {}", dedup_stats.unique));
168 lines.push(format!("- **Duplicates removed:** {}", dedup_stats.removed));
169 if has_nvd {
170 lines.push("- **NVD enrichment:** Enabled".to_string());
171 }
172 lines.push(String::new());
173
174 let mut severity_counts = BTreeMap::<String, usize>::new();
175 for record in records {
176 let severity = record.severity.to_lowercase();
177 *severity_counts.entry(severity).or_insert(0) += 1;
178 }
179
180 lines.push("### Severity Distribution".to_string());
181 lines.push(String::new());
182 for severity in ["critical", "high", "medium", "low", "info", "unknown"] {
183 if let Some(count) = severity_counts.get(severity) {
184 if *count > 0 {
185 lines.push(format!("- **{}:** {}", severity.to_uppercase(), count));
186 }
187 }
188 }
189 lines.push(String::new());
190
191 lines.push("## Findings".to_string());
192 lines.push(String::new());
193
194 if has_nvd {
195 lines.push(
196 "| # | Target | Tool | Title | Severity | Port | CVE | CVSS v3 | NVD Severity |"
197 .to_string(),
198 );
199 lines.push(
200 "|---|--------|------|-------|----------|------|-----|---------|--------------|"
201 .to_string(),
202 );
203 for (idx, record) in records.iter().enumerate() {
204 let cvss = record
205 .nvd_cvss_v3
206 .map(|value| format!("{value:.1}"))
207 .unwrap_or_else(|| "—".to_string());
208 let nvd_sev = record
209 .nvd_cvss_v3_severity
210 .clone()
211 .unwrap_or_else(|| "—".to_string())
212 .to_uppercase();
213 lines.push(format!(
214 "| {} | {} | {} | {} | {} | {} | {} | {} | {} |",
215 idx + 1,
216 md_escape(&record.target),
217 md_escape(&record.tool),
218 md_escape(&record.title),
219 record.severity.to_uppercase(),
220 record
221 .port
222 .map(|port| port.to_string())
223 .unwrap_or_else(|| "—".to_string()),
224 md_escape(record.cve.as_deref().unwrap_or("—")),
225 cvss,
226 nvd_sev
227 ));
228 }
229 } else {
230 lines.push("| # | Target | Tool | Title | Severity | Port | CVE |".to_string());
231 lines.push("|---|--------|------|-------|----------|------|-----|".to_string());
232 for (idx, record) in records.iter().enumerate() {
233 lines.push(format!(
234 "| {} | {} | {} | {} | {} | {} | {} |",
235 idx + 1,
236 md_escape(&record.target),
237 md_escape(&record.tool),
238 md_escape(&record.title),
239 record.severity.to_uppercase(),
240 record
241 .port
242 .map(|port| port.to_string())
243 .unwrap_or_else(|| "—".to_string()),
244 md_escape(record.cve.as_deref().unwrap_or("—"))
245 ));
246 }
247 }
248 lines.push(String::new());
249
250 if has_nvd {
251 lines.push("## CVE Details (NVD)".to_string());
252 lines.push(String::new());
253 for record in records {
254 let cve = match record.cve.as_ref() {
255 Some(cve) => cve,
256 None => continue,
257 };
258
259 lines.push(format!("### {}", cve));
260 lines.push(String::new());
261 if let Some(cvss) = record.nvd_cvss_v3 {
262 lines.push(format!("- **CVSS v3 Score:** {:.1}", cvss));
263 }
264 if let Some(severity) = record.nvd_cvss_v3_severity.as_ref() {
265 lines.push(format!("- **NVD Severity:** {}", severity.to_uppercase()));
266 }
267 if let Some(published) = record.nvd_published.as_ref() {
268 lines.push(format!("- **Published:** {}", published));
269 }
270 if let Some(description) = record.nvd_description.as_ref() {
271 if !description.is_empty() {
272 lines.push(format!("- **Description:** {}", description));
273 }
274 }
275 if !record.nvd_references.is_empty() {
276 lines.push("- **References:**".to_string());
277 for reference in &record.nvd_references {
278 lines.push(format!(" - {}", reference));
279 }
280 }
281 lines.push(String::new());
282 }
283 }
284
285 if !suggestions.is_empty() {
286 lines.push("## Metasploit Module Suggestions".to_string());
287 lines.push(String::new());
288 lines.push("| Service Banner | Suggested Module | Confidence |".to_string());
289 lines.push("|---------------|------------------|------------|".to_string());
290 for suggestion in suggestions {
291 lines.push(format!(
292 "| {} | `{}` | {} |",
293 md_escape(&suggestion.service_banner),
294 suggestion.suggested_module,
295 suggestion.confidence.to_uppercase()
296 ));
297 }
298 lines.push(String::new());
299 }
300
301 if !recipes.is_empty() {
302 lines.push("## Payload Command Recipes".to_string());
303 lines.push(String::new());
304 for recipe in recipes {
305 lines.push(format!(
306 "### {} / {}",
307 title_case(&recipe.platform),
308 title_case(&recipe.architecture)
309 ));
310 lines.push(String::new());
311 lines.push(format!("- **LHOST:** {}", recipe.lhost));
312 lines.push(format!("- **LPORT:** {}", recipe.lport));
313 lines.push(format!("- **Format:** {}", recipe.format));
314 lines.push(format!("- **Output:** {}", recipe.output_path));
315 lines.push(String::new());
316 lines.push("```bash".to_string());
317 lines.push(recipe.command.clone());
318 lines.push("```".to_string());
319 lines.push(String::new());
320 }
321 }
322
323 lines.push("---".to_string());
324 lines.push("*Report generated by PloitMalper v0.1.0*".to_string());
325 lines.push(String::new());
326
327 let content = lines.join("\n");
328 fs::write(output_path, content)?;
329 Ok(PathBuf::from(output_path))
330}
331
332fn md_escape(value: &str) -> String {
333 value.replace('|', r"\|")
334}
335
336fn title_case(value: &str) -> String {
337 value
338 .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
339 .filter(|segment| !segment.is_empty())
340 .map(|segment| {
341 let mut chars = segment.chars();
342 match chars.next() {
343 Some(first) => {
344 let first = first.to_uppercase().collect::<String>();
345 format!("{}{}", first, chars.as_str().to_lowercase())
346 }
347 None => String::new(),
348 }
349 })
350 .collect::<Vec<_>>()
351 .join(" ")
352}