Skip to main content

ploit_malper/
report.rs

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}