Skip to main content

ploit_malper/
report.rs

1use std::fs;
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::analyze::{
6    build_attack_chain, build_scan_summary, categorize_modules, compute_risk_score,
7    exploitability_assessment, group_findings, manual_exploitation_notes, map_mitre,
8};
9use crate::enrich;
10use crate::error::Result;
11use crate::models::{
12    DedupStats, ExploitabilityInfo, FindingGroup, InjectableEndpoint, ModuleCategories,
13    ModuleSuggestion, PayloadRecipe, ScanRecord, ScanSummary,
14};
15
16fn truncate(value: &str, limit: usize) -> String {
17    let count = value.chars().count();
18    if count <= limit {
19        value.to_string()
20    } else if limit <= 1 {
21        "…".to_string()
22    } else {
23        let mut truncated = value.chars().take(limit - 1).collect::<String>();
24        truncated.push('…');
25        truncated
26    }
27}
28
29fn pad(value: &str, width: usize) -> String {
30    let visible = value.chars().count();
31    if visible >= width {
32        value.to_string()
33    } else {
34        format!("{}{}", value, " ".repeat(width - visible))
35    }
36}
37
38fn ascii_table(title: &str, headers: &[&str], rows: &[Vec<String>]) -> String {
39    let mut widths = headers
40        .iter()
41        .map(|h| h.chars().count())
42        .collect::<Vec<_>>();
43    for row in rows {
44        for (idx, cell) in row.iter().enumerate() {
45            widths[idx] = widths[idx].max(cell.chars().count());
46        }
47    }
48
49    let separator = {
50        let mut s = String::from("+");
51        for width in &widths {
52            s.push_str(&"-".repeat(*width + 2));
53            s.push('+');
54        }
55        s
56    };
57
58    let mut out = String::new();
59    out.push_str(title);
60    out.push('\n');
61    out.push_str(&separator);
62    out.push('\n');
63    out.push('|');
64    for (idx, header) in headers.iter().enumerate() {
65        out.push(' ');
66        out.push_str(&pad(header, widths[idx]));
67        out.push(' ');
68        out.push('|');
69    }
70    out.push('\n');
71    out.push_str(&separator);
72    out.push('\n');
73    for row in rows {
74        out.push('|');
75        for (idx, cell) in row.iter().enumerate() {
76            out.push(' ');
77            out.push_str(&pad(cell, widths[idx]));
78            out.push(' ');
79            out.push('|');
80        }
81        out.push('\n');
82    }
83    out.push_str(&separator);
84    out
85}
86
87fn unique_cves(records: &[ScanRecord]) -> Vec<String> {
88    let mut seen = std::collections::BTreeSet::new();
89    let mut out = Vec::new();
90    for cve in records.iter().flat_map(|record| record.cves.iter()) {
91        let key = cve.trim().to_uppercase();
92        if seen.insert(key.clone()) {
93            out.push(key);
94        }
95    }
96    out
97}
98
99fn technologies(records: &[ScanRecord]) -> Vec<String> {
100    let mut seen = std::collections::BTreeSet::new();
101    let mut out = Vec::new();
102    for record in records {
103        let mut candidates = Vec::new();
104        if let Some(service) = record.service.as_deref() {
105            candidates.push(service.to_string());
106        }
107        for key in [
108            "server",
109            "banner",
110            "software",
111            "product",
112            "app",
113            "technology",
114        ] {
115            if let Some(value) = record.raw.get(key).and_then(serde_json::Value::as_str) {
116                candidates.push(value.to_string());
117            }
118        }
119        for candidate in candidates {
120            let lower = candidate.to_lowercase();
121            if seen.insert(lower.clone()) {
122                out.push(lower);
123            }
124        }
125    }
126    out
127}
128
129// ---------------------------------------------------------------------------
130// Console rendering
131// ---------------------------------------------------------------------------
132
133fn console_executive_summary(
134    summary: &ScanSummary,
135    dedup_stats: &DedupStats,
136    injectable: &[InjectableEndpoint],
137    cve_count: usize,
138) -> String {
139    let mut out = String::new();
140    out.push_str("Executive Summary\n");
141    out.push_str("─────────────────\n");
142    out.push_str(&format!("Host: {}\n", summary.host));
143    if let Some(server) = &summary.server {
144        out.push_str(&format!("Server: {}\n", server));
145    }
146    if let Some(framework) = &summary.framework {
147        out.push_str(&format!("Framework: {}\n", framework));
148    }
149    out.push_str(&format!("Records processed: {}\n", dedup_stats.total));
150    out.push_str(&format!("Unique findings: {}\n", dedup_stats.unique));
151    out.push_str(&format!("Duplicates merged: {}\n", dedup_stats.removed));
152    out.push_str(&format!("Injectable endpoints: {}\n", injectable.len()));
153    out.push_str(&format!("CVE ids discovered: {}\n", cve_count));
154    out.push('\n');
155    for severity in ["critical", "high", "medium", "low", "info"] {
156        if let Some(count) = summary.severity_counts.get(severity) {
157            if *count > 0 {
158                out.push_str(&format!(
159                    "{:<9}: {}\n",
160                    format!("{}", severity.to_uppercase()),
161                    count
162                ));
163            }
164        }
165    }
166    out
167}
168
169fn console_risk_overview(records: &[ScanRecord], injectable: &[InjectableEndpoint]) -> String {
170    let risk = compute_risk_score(records, injectable);
171    let mut out = String::new();
172    out.push_str(&format!("Overall Risk: {}/100\n", risk.score));
173    out.push_str(&risk.bar);
174    out.push_str("\n\nReason:\n");
175    for reason in &risk.reasons {
176        out.push_str(&format!("• {}\n", reason));
177    }
178    out
179}
180
181fn console_key_findings(groups: &[FindingGroup]) -> String {
182    let mut out = String::new();
183    out.push_str("Key Findings\n");
184    out.push_str("────────────\n");
185    let actionable: Vec<&FindingGroup> = groups
186        .iter()
187        .filter(|group| group.family != "Reconnaissance / Informational")
188        .collect();
189    let selection: Vec<&FindingGroup> = if actionable.is_empty() {
190        groups.iter().take(5).collect()
191    } else {
192        actionable.iter().take(6).cloned().collect()
193    };
194    if selection.is_empty() {
195        out.push_str("(none)\n");
196        return out;
197    }
198    for (idx, group) in selection.iter().enumerate() {
199        let cve_part = if group.cves.is_empty() {
200            String::new()
201        } else {
202            format!(" [{}]", group.cves.join(", "))
203        };
204        out.push_str(&format!(
205            "{}. {} — {} ({} finding{}){}\n",
206            idx + 1,
207            group.title,
208            group.severity.to_uppercase(),
209            group.count,
210            if group.count == 1 { "" } else { "s" },
211            cve_part
212        ));
213    }
214    out
215}
216
217fn console_injectable(injectable: &[InjectableEndpoint]) -> String {
218    let mut out = String::new();
219    out.push_str("Injectable Endpoints\n");
220    out.push_str("────────────────────\n");
221    if injectable.is_empty() {
222        out.push_str("No injectable endpoints flagged by the scanner.\n");
223        return out;
224    }
225    out.push_str(&format!(
226        "{} potential injection point(s) reported:\n",
227        injectable.len()
228    ));
229    for (idx, entry) in injectable.iter().enumerate() {
230        out.push_str(&format!("  {}. {}\n", idx + 1, entry.endpoint));
231        out.push_str(&format!("     (affected host: {})\n", entry.target));
232    }
233    out
234}
235
236fn console_cve_intelligence(records: &[ScanRecord]) -> String {
237    let mut out = String::new();
238    out.push_str("CVE Intelligence\n");
239    out.push_str("────────────────\n");
240    let cves = unique_cves(records);
241    if cves.is_empty() {
242        out.push_str("No CVEs discovered in this scan.\n");
243        return out;
244    }
245    for cve in &cves {
246        let record = records.iter().find(|record| {
247            record
248                .cves
249                .iter()
250                .any(|known| known.eq_ignore_ascii_case(cve))
251        });
252        out.push_str(&format!("{}\n", cve));
253        if let Some(record) = record {
254            if let Some(cvss) = record.nvd_cvss_v3 {
255                out.push_str(&format!("  CVSS v3: {:.1}", cvss));
256                if let Some(severity) = record.nvd_cvss_v3_severity.as_deref() {
257                    out.push_str(&format!(" ({})", severity.to_uppercase()));
258                }
259                out.push('\n');
260            }
261            if let Some(cvss) = record.nvd_cvss_v2 {
262                out.push_str(&format!("  CVSS v2: {:.1}\n", cvss));
263            }
264            if !record.nvd_weaknesses.is_empty() {
265                out.push_str(&format!("  CWE: {}\n", record.nvd_weaknesses.join(", ")));
266            }
267            if let Some(published) = record.nvd_published.as_deref() {
268                if !published.is_empty() {
269                    out.push_str(&format!("  Published: {}\n", published));
270                }
271            }
272        }
273        if let Some(extra) = enrich::enrich_cve(cve) {
274            out.push_str(&format!("  EPSS: {}\n", enrich::epss_percent(extra.epss)));
275            out.push_str(&format!(
276                "  Known Exploited: {}\n",
277                if extra.in_kev { "YES" } else { "NO" }
278            ));
279            out.push_str(&format!(
280                "  Public exploit: {}\n",
281                if extra.public_exploit { "YES" } else { "NO" }
282            ));
283        }
284        if let Some(record) = record {
285            if let Some(description) = record.nvd_description.as_deref() {
286                if !description.is_empty() {
287                    out.push_str(&format!("  Description: {}\n", truncate(description, 120)));
288                }
289            }
290        }
291        out.push('\n');
292    }
293    out
294}
295
296fn console_exploitability(assessment: &ExploitabilityInfo) -> String {
297    let mut out = String::new();
298    out.push_str(&format!(
299        "Exploitability Assessment: {} ({}/100)\n",
300        assessment.rating, assessment.score
301    ));
302    out.push_str("────────────────────────────────────────────\n");
303    for reason in &assessment.reasons {
304        out.push_str(&format!("• {}\n", reason));
305    }
306    out
307}
308
309fn console_msf(records: &[ScanRecord], injectable: &[InjectableEndpoint]) -> String {
310    let mut out = String::new();
311    out.push_str("Metasploit Recommendations\n");
312    out.push_str("──────────────────────────\n");
313
314    let cves = unique_cves(records);
315    let mut any = false;
316    for cve in &cves {
317        let modules = crate::msf::modules_for_cve(cve);
318        if modules.is_empty() {
319            let reason = crate::msf::no_module_reason(cve);
320            out.push_str(&format!("• {} — {}\n", cve, reason.reason));
321            any = true;
322            continue;
323        }
324        any = true;
325        out.push_str(&format!("{}\n", cve));
326        for module in &modules {
327            out.push_str(&format!("  ✓ {} (rank: {})\n", module.name, module.rank));
328            out.push_str(&format!("     disclosure: {}\n", module.disclosure_date));
329            out.push_str(&format!(
330                "     platforms: {}\n",
331                if module.platforms.is_empty() {
332                    "any".to_string()
333                } else {
334                    module.platforms.join(", ")
335                }
336            ));
337            if !module.required_options.is_empty() {
338                out.push_str(&format!(
339                    "     required options: {}\n",
340                    module.required_options.join(", ")
341                ));
342            }
343        }
344    }
345
346    let tech = technologies(records);
347    if !tech.is_empty() {
348        let modules = crate::msf::modules_for_technology(&tech);
349        if !modules.is_empty() {
350            out.push_str("Technology-driven modules:\n");
351            for module in &modules {
352                out.push_str(&format!(
353                    "  ✓ {} (rank: {}, disclosure: {})\n",
354                    module.name, module.rank, module.disclosure_date
355                ));
356                out.push_str(&format!(
357                    "     platforms: {}\n",
358                    if module.platforms.is_empty() {
359                        "any".to_string()
360                    } else {
361                        module.platforms.join(", ")
362                    }
363                ));
364            }
365            any = true;
366        }
367    }
368
369    if !injectable.is_empty() {
370        out.push_str("• Injectable endpoints: no Metasploit module can validate an application\n");
371        out.push_str(
372            "  injection point automatically — manual testing is required (see Attack Chain).\n",
373        );
374        any = true;
375    }
376
377    if !any {
378        out.push_str("(no CVEs or recognized technologies warrant a Metasploit module)\n");
379    }
380    out
381}
382
383fn console_attack_chain(records: &[ScanRecord], injectable: &[InjectableEndpoint]) -> String {
384    let mut out = String::new();
385    out.push_str("Attack Chain\n");
386    out.push_str("────────────\n");
387    match build_attack_chain(records, injectable) {
388        Some(steps) => {
389            for (idx, step) in steps.iter().enumerate() {
390                if idx > 0 {
391                    out.push_str("     │\n     ▼\n");
392                }
393                out.push_str(&format!("{}\n", step));
394            }
395        }
396        None => {
397            out.push_str("No credible automated attack chain identified.\n");
398            out.push_str(
399                "No injectable endpoints or known-exploitable CVEs were found in this scan.\n",
400            );
401        }
402    }
403    out
404}
405
406fn console_full_findings(groups: &[FindingGroup]) -> String {
407    let mut out = String::new();
408    out.push_str("Full Findings\n");
409    out.push_str("─────────────\n");
410
411    let rows = groups
412        .iter()
413        .enumerate()
414        .map(|(idx, group)| {
415            vec![
416                (idx + 1).to_string(),
417                truncate(&group.title, 34),
418                group.severity.to_uppercase(),
419                group.count.to_string(),
420                group.affected_endpoints.len().to_string(),
421                if group.cves.is_empty() {
422                    "—".to_string()
423                } else {
424                    group.cves.join(", ")
425                },
426            ]
427        })
428        .collect::<Vec<_>>();
429
430    if !rows.is_empty() {
431        out.push_str(&ascii_table(
432            "Grouped Intelligence Findings",
433            &["#", "Finding", "Severity", "Count", "Endpoints", "CVE"],
434            &rows,
435        ));
436        out.push('\n');
437    }
438
439    for group in groups {
440        out.push_str(&format!(
441            "{} — {} ({} finding{})\n",
442            group.title,
443            group.severity.to_uppercase(),
444            group.count,
445            if group.count == 1 { "" } else { "s" }
446        ));
447        if !group.details.is_empty() {
448            for detail in &group.details {
449                out.push_str(&format!("  • {}\n", detail));
450            }
451        }
452        if !group.affected_endpoints.is_empty() {
453            out.push_str("  endpoints:\n");
454            for endpoint in &group.affected_endpoints {
455                out.push_str(&format!("     {}\n", endpoint));
456            }
457        }
458        if !group.cves.is_empty() {
459            out.push_str(&format!("  cves: {}\n", group.cves.join(", ")));
460        }
461        if !group.ports.is_empty() {
462            out.push_str(&format!("  ports: {}\n", group.ports.join(", ")));
463        }
464        if !group.tools.is_empty() {
465            out.push_str(&format!("  tools: {}\n", group.tools.join(", ")));
466        }
467        out.push_str(&format!("  target: {}\n", group.target));
468        if !group.evidence.is_empty() {
469            out.push_str("  evidence:\n");
470            for evidence in &group.evidence {
471                out.push_str(&format!("     - {}\n", evidence));
472            }
473        }
474        out.push('\n');
475    }
476    out
477}
478
479fn record_evidence(record: &ScanRecord) -> String {
480    let mut lines = Vec::new();
481    lines.push(format!("tool: {}", record.tool));
482    lines.push(format!("target: {}", record.target));
483    if !record.raw_findings.is_empty() {
484        for raw in &record.raw_findings {
485            lines.push(format!("raw finding: {}", raw));
486        }
487    } else {
488        lines.push(format!("title: {}", record.title));
489    }
490    if !record.detail.is_empty() {
491        lines.push(format!("detail: {}", record.detail));
492    }
493    if !record.reference.is_empty() {
494        lines.push(format!("reference: {}", record.reference));
495    }
496    if !record.normalized_title.is_empty() {
497        lines.push(format!("normalized: {}", record.normalized_title));
498    }
499    lines.push(format!("raw json: {}", record.raw));
500    lines.join("\n")
501}
502
503fn console_raw_evidence(records: &[ScanRecord]) -> String {
504    let mut out = String::new();
505    out.push_str("Raw Evidence\n");
506    out.push_str("────────────\n");
507    for (idx, record) in records.iter().enumerate() {
508        out.push_str(&format!("[{}]\n", idx + 1));
509        for line in record_evidence(record).lines() {
510            out.push_str(&format!("  {}\n", line));
511        }
512        out.push('\n');
513    }
514    out
515}
516
517pub fn render_console_report(
518    records: &[ScanRecord],
519    injectable: &[InjectableEndpoint],
520    _suggestions: &[ModuleSuggestion],
521    recipes: &[PayloadRecipe],
522    dedup_stats: &DedupStats,
523) -> String {
524    let summary = build_scan_summary(records);
525    let groups = group_findings(records);
526    let assessment = exploitability_assessment(records, injectable);
527    let cve_count = unique_cves(records).len();
528
529    let mut out = String::new();
530    out.push_str(&console_executive_summary(
531        &summary,
532        dedup_stats,
533        injectable,
534        cve_count,
535    ));
536    out.push('\n');
537    out.push_str(&console_risk_overview(records, injectable));
538    out.push('\n');
539    out.push_str(&console_key_findings(&groups));
540    out.push('\n');
541    out.push_str(&console_injectable(injectable));
542    out.push('\n');
543    out.push_str(&console_cve_intelligence(records));
544    out.push('\n');
545    out.push_str(&console_exploitability(&assessment));
546    out.push('\n');
547    out.push_str(&console_msf(records, injectable));
548    out.push('\n');
549    out.push_str(&console_attack_chain(records, injectable));
550    out.push('\n');
551    out.push_str(&console_full_findings(&groups));
552    out.push('\n');
553    out.push_str(&console_raw_evidence(records));
554    if !recipes.is_empty() {
555        out.push('\n');
556        out.push_str(&render_recipes(recipes));
557    }
558    out
559}
560
561fn render_recipes(recipes: &[PayloadRecipe]) -> String {
562    let mut out = String::new();
563    out.push_str("Payload Command Recipes\n");
564    for recipe in recipes {
565        out.push_str(&format!(
566            "{} / {}\n  LHOST: {}\n  LPORT: {}\n  Format: {}\n  Output: {}\n\n  {}\n\n",
567            recipe.platform,
568            recipe.architecture,
569            recipe.lhost,
570            recipe.lport,
571            recipe.format,
572            recipe.output_path,
573            recipe.command
574        ));
575    }
576    out
577}
578
579// ---------------------------------------------------------------------------
580// Markdown report
581// ---------------------------------------------------------------------------
582
583fn md_escape(value: &str) -> String {
584    value.replace('|', r"\|")
585}
586
587fn title_case(value: &str) -> String {
588    value
589        .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
590        .filter(|segment| !segment.is_empty())
591        .map(|segment| {
592            let mut chars = segment.chars();
593            match chars.next() {
594                Some(first) => {
595                    let first = first.to_uppercase().collect::<String>();
596                    format!("{}{}", first, chars.as_str().to_lowercase())
597                }
598                None => String::new(),
599            }
600        })
601        .collect::<Vec<_>>()
602        .join(" ")
603}
604
605pub fn generate_markdown_report(
606    records: &[ScanRecord],
607    injectable: &[InjectableEndpoint],
608    _suggestions: &[ModuleSuggestion],
609    recipes: &[PayloadRecipe],
610    dedup_stats: &DedupStats,
611    output_path: &str,
612) -> Result<PathBuf> {
613    let mut lines = Vec::new();
614    let has_nvd = records.iter().any(|record| record.nvd_cvss_v3.is_some());
615
616    lines.push("# PloitMalper - Intelligence Analysis Report".to_string());
617    lines.push(String::new());
618    lines.push(format!(
619        "**Generated:** {}",
620        SystemTime::now()
621            .duration_since(UNIX_EPOCH)
622            .map(|d| d.as_secs().to_string())
623            .unwrap_or_else(|_| "unknown".to_string())
624    ));
625    lines.push(format!("**Tool Version:** {}", env!("CARGO_PKG_VERSION")));
626    lines.push(String::new());
627
628    // 1. Executive Summary
629    let summary = build_scan_summary(records);
630    lines.push("## 1. Executive Summary".to_string());
631    lines.push(String::new());
632    lines.push(format!("- **Host:** {}", md_escape(&summary.host)));
633    if let Some(server) = &summary.server {
634        lines.push(format!("- **Server:** {}", md_escape(server)));
635    }
636    if let Some(framework) = &summary.framework {
637        lines.push(format!("- **Framework:** {}", md_escape(framework)));
638    }
639    lines.push(format!(
640        "- **Total records processed:** {}",
641        dedup_stats.total
642    ));
643    lines.push(format!("- **Unique findings:** {}", dedup_stats.unique));
644    lines.push(format!("- **Duplicates merged:** {}", dedup_stats.removed));
645    lines.push(format!("- **Injectable endpoints:** {}", injectable.len()));
646    lines.push(format!(
647        "- **CVE ids discovered:** {}",
648        unique_cves(records).len()
649    ));
650    if has_nvd {
651        lines.push("- **NVD enrichment:** Enabled".to_string());
652    }
653    lines.push(String::new());
654
655    lines.push("### Severity Distribution".to_string());
656    lines.push(String::new());
657    for severity in ["critical", "high", "medium", "low", "info", "unknown"] {
658        if let Some(count) = summary.severity_counts.get(severity) {
659            if *count > 0 {
660                lines.push(format!("- **{}:** {}", severity.to_uppercase(), count));
661            }
662        }
663    }
664    lines.push(String::new());
665
666    // 2. Risk Overview
667    let risk = compute_risk_score(records, injectable);
668    lines.push("## 2. Risk Overview".to_string());
669    lines.push(String::new());
670    lines.push(format!("### Overall Risk: {}/100", risk.score));
671    lines.push(String::new());
672    lines.push(format!("`{}`", risk.bar));
673    lines.push(String::new());
674    for reason in &risk.reasons {
675        lines.push(format!("- {}", reason));
676    }
677    lines.push(String::new());
678
679    // 3. Key Findings
680    let groups = group_findings(records);
681    lines.push("## 3. Key Findings".to_string());
682    lines.push(String::new());
683    let actionable: Vec<&FindingGroup> = groups
684        .iter()
685        .filter(|group| group.family != "Reconnaissance / Informational")
686        .collect();
687    let selection: Vec<&FindingGroup> = if actionable.is_empty() {
688        groups.iter().take(5).collect()
689    } else {
690        actionable.iter().take(6).cloned().collect()
691    };
692    if selection.is_empty() {
693        lines.push("No significant findings to highlight.".to_string());
694    } else {
695        for (idx, group) in selection.iter().enumerate() {
696            let cve_part = if group.cves.is_empty() {
697                String::new()
698            } else {
699                format!(" `{}`", group.cves.join(", "))
700            };
701            lines.push(format!(
702                "{}. **{}** — {} ({} finding{}){}",
703                idx + 1,
704                group.title,
705                group.severity.to_uppercase(),
706                group.count,
707                if group.count == 1 { "" } else { "s" },
708                cve_part
709            ));
710        }
711    }
712    lines.push(String::new());
713
714    // 4. Injectable Endpoints
715    lines.push("## 4. Injectable Endpoints".to_string());
716    lines.push(String::new());
717    if injectable.is_empty() {
718        lines.push("No injectable endpoints flagged by the scanner.".to_string());
719    } else {
720        lines.push(format!(
721            "{} potential injection point(s) reported:",
722            injectable.len()
723        ));
724        lines.push(String::new());
725        lines.push("| # | Endpoint | Affected Host |".to_string());
726        lines.push("|---|----------|---------------|".to_string());
727        for (idx, entry) in injectable.iter().enumerate() {
728            lines.push(format!(
729                "| {} | `{}` | {} |",
730                idx + 1,
731                md_escape(&entry.endpoint),
732                md_escape(&entry.target)
733            ));
734        }
735    }
736    lines.push(String::new());
737
738    // 5. CVE Intelligence
739    lines.push("## 5. CVE Intelligence".to_string());
740    lines.push(String::new());
741    let cves = unique_cves(records);
742    if cves.is_empty() {
743        lines.push("No CVEs were discovered in this scan.".to_string());
744    } else {
745        for cve in &cves {
746            let record = records.iter().find(|record| {
747                record
748                    .cves
749                    .iter()
750                    .any(|known| known.eq_ignore_ascii_case(cve))
751            });
752            lines.push(format!("### {}", cve));
753            lines.push(String::new());
754            if let Some(record) = record {
755                if let Some(cvss) = record.nvd_cvss_v3 {
756                    let severity = record
757                        .nvd_cvss_v3_severity
758                        .as_deref()
759                        .map(|s| format!(" ({})", s.to_uppercase()))
760                        .unwrap_or_default();
761                    lines.push(format!("- **CVSS v3 Score:** {:.1}{}", cvss, severity));
762                }
763                if let Some(cvss) = record.nvd_cvss_v2 {
764                    lines.push(format!("- **CVSS v2 Score:** {:.1}", cvss));
765                }
766                if !record.nvd_weaknesses.is_empty() {
767                    lines.push(format!("- **CWE:** {}", record.nvd_weaknesses.join(", ")));
768                }
769                if let Some(published) = record.nvd_published.as_deref() {
770                    if !published.is_empty() {
771                        lines.push(format!("- **Published:** {}", published));
772                    }
773                }
774                if let Some(description) = record.nvd_description.as_deref() {
775                    if !description.is_empty() {
776                        lines.push(format!("- **Description:** {}", description));
777                    }
778                }
779            }
780            if let Some(extra) = enrich::enrich_cve(cve) {
781                lines.push(format!("- **EPSS:** {}", enrich::epss_percent(extra.epss)));
782                lines.push(format!(
783                    "- **Known Exploited (CISA KEV):** {}",
784                    if extra.in_kev { "YES" } else { "NO" }
785                ));
786                lines.push(format!(
787                    "- **Public exploit:** {}",
788                    if extra.public_exploit { "YES" } else { "NO" }
789                ));
790            }
791            if let Some(record) = record {
792                if !record.nvd_references.is_empty() {
793                    lines.push("- **References:**".to_string());
794                    for reference in &record.nvd_references {
795                        lines.push(format!("  - {}", reference));
796                    }
797                }
798            }
799            lines.push(String::new());
800        }
801    }
802
803    // 6. Exploitability Assessment
804    let assessment = exploitability_assessment(records, injectable);
805    lines.push("## 6. Exploitability Assessment".to_string());
806    lines.push(String::new());
807    lines.push(format!(
808        "**Rating:** {} ({}/100)",
809        assessment.rating, assessment.score
810    ));
811    lines.push(String::new());
812    for reason in &assessment.reasons {
813        lines.push(format!("- {}", reason));
814    }
815    lines.push(String::new());
816
817    // 7. Metasploit Recommendations
818    lines.push("## 7. Metasploit Recommendations".to_string());
819    lines.push(String::new());
820    let mut msf_any = false;
821    for cve in &cves {
822        let modules = crate::msf::modules_for_cve(cve);
823        if modules.is_empty() {
824            let reason = crate::msf::no_module_reason(cve);
825            lines.push(format!("**{}** — {}", cve, reason.reason));
826            lines.push(String::new());
827            msf_any = true;
828            continue;
829        }
830        msf_any = true;
831        lines.push(format!("### {}", cve));
832        lines.push(String::new());
833        for module in &modules {
834            lines.push(format!("- **Module:** `{}`", module.name));
835            lines.push(format!("  - **Rank:** {}", module.rank));
836            lines.push(format!("  - **Disclosure:** {}", module.disclosure_date));
837            lines.push(format!(
838                "  - **Platforms:** {}",
839                if module.platforms.is_empty() {
840                    "any".to_string()
841                } else {
842                    module.platforms.join(", ")
843                }
844            ));
845            lines.push(format!(
846                "  - **Required options:** {}",
847                if module.required_options.is_empty() {
848                    "none".to_string()
849                } else {
850                    module.required_options.join(", ")
851                }
852            ));
853        }
854        lines.push(String::new());
855    }
856    let tech = technologies(records);
857    if !tech.is_empty() {
858        let modules = crate::msf::modules_for_technology(&tech);
859        if !modules.is_empty() {
860            lines.push("### Technology-driven".to_string());
861            lines.push(String::new());
862            for module in &modules {
863                lines.push(format!(
864                    "- `{}` (rank: {}, disclosure: {})",
865                    module.name, module.rank, module.disclosure_date
866                ));
867            }
868            lines.push(String::new());
869            msf_any = true;
870        }
871    }
872    if !injectable.is_empty() {
873        lines.push(
874            "- **Injectable endpoints:** no Metasploit module can validate an application"
875                .to_string(),
876        );
877        lines.push(
878            "  injection point automatically — manual testing is required (see Attack Chain)."
879                .to_string(),
880        );
881        lines.push(String::new());
882        msf_any = true;
883    }
884    if !msf_any {
885        lines.push("No CVEs or recognized technologies warrant a Metasploit module.".to_string());
886        lines.push(String::new());
887    }
888
889    // 8. MITRE ATT&CK Mapping
890    let tactics = map_mitre(records);
891    lines.push("## 8. MITRE ATT&CK Mapping".to_string());
892    lines.push(String::new());
893    if tactics.is_empty() {
894        lines.push("No applicable tactics.".to_string());
895    } else {
896        for tactic in &tactics {
897            lines.push(format!("### {}", tactic.tactic));
898            lines.push(String::new());
899            for technique in &tactic.techniques {
900                lines.push(format!("- ✓ {}", technique));
901            }
902            lines.push(String::new());
903        }
904    }
905
906    // 9. Attack Chain
907    lines.push("## 9. Attack Chain".to_string());
908    lines.push(String::new());
909    match build_attack_chain(records, injectable) {
910        Some(steps) => {
911            lines.push("```text".to_string());
912            for (idx, step) in steps.iter().enumerate() {
913                if idx > 0 {
914                    lines.push("     │".to_string());
915                    lines.push("     ▼".to_string());
916                }
917                lines.push(step.clone());
918            }
919            lines.push("```".to_string());
920        }
921        None => {
922            lines.push("**No credible automated attack chain identified.**".to_string());
923            lines.push(String::new());
924            lines.push(
925                "No injectable endpoints or known-exploitable CVEs were found in this scan; chaining steps without evidence would be speculative.".to_string(),
926            );
927        }
928    }
929    lines.push(String::new());
930
931    // 10. Full Findings
932    lines.push("## 10. Full Findings".to_string());
933    lines.push(String::new());
934    lines.push("| # | Finding | Severity | Count | Endpoints | CVE |".to_string());
935    lines.push("|---|---------|----------|-------|-----------|-----|".to_string());
936    for (idx, group) in groups.iter().enumerate() {
937        lines.push(format!(
938            "| {} | {} | {} | {} | {} | {} |",
939            idx + 1,
940            md_escape(&group.title),
941            group.severity.to_uppercase(),
942            group.count,
943            if group.affected_endpoints.is_empty() {
944                "—".to_string()
945            } else {
946                group.affected_endpoints.len().to_string()
947            },
948            if group.cves.is_empty() {
949                "—".to_string()
950            } else {
951                group.cves.join(", ")
952            }
953        ));
954    }
955    lines.push(String::new());
956    for group in &groups {
957        lines.push(format!(
958            "### {} — {}",
959            group.title,
960            group.severity.to_uppercase()
961        ));
962        lines.push(String::new());
963        lines.push(format!("- **Count:** {}", group.count));
964        lines.push(format!("- **Target:** {}", md_escape(&group.target)));
965        if !group.ports.is_empty() {
966            lines.push(format!("- **Ports:** {}", group.ports.join(", ")));
967        }
968        if !group.tools.is_empty() {
969            lines.push(format!("- **Tools:** {}", group.tools.join(", ")));
970        }
971        if !group.cves.is_empty() {
972            lines.push(format!("- **CVEs:** {}", group.cves.join(", ")));
973        }
974        if !group.details.is_empty() {
975            lines.push("- **Details:**".to_string());
976            for detail in &group.details {
977                lines.push(format!("  - {}", md_escape(detail)));
978            }
979        }
980        if !group.affected_endpoints.is_empty() {
981            lines.push("- **Affected endpoints:**".to_string());
982            for endpoint in &group.affected_endpoints {
983                lines.push(format!("  - `{}`", md_escape(endpoint)));
984            }
985        }
986        if !group.evidence.is_empty() {
987            lines.push("- **Scanner evidence:**".to_string());
988            for evidence in &group.evidence {
989                lines.push(format!("  - {}", md_escape(evidence)));
990            }
991        }
992        lines.push(String::new());
993    }
994
995    // 11. Raw Evidence
996    lines.push("## 11. Raw Evidence".to_string());
997    lines.push(String::new());
998    lines.push(
999        "Every scanner finding below maps back to the original VulnMalper output for full traceability.".to_string(),
1000    );
1001    lines.push(String::new());
1002    for (idx, record) in records.iter().enumerate() {
1003        lines.push(format!("### {}. {}", idx + 1, md_escape(&record.title)));
1004        lines.push(String::new());
1005        lines.push(format!("- **Tool:** {}", record.tool));
1006        lines.push(format!("- **Target:** {}", md_escape(&record.target)));
1007        if !record.normalized_title.is_empty() {
1008            lines.push(format!(
1009                "- **Normalized:** `{}`",
1010                md_escape(&record.normalized_title)
1011            ));
1012        }
1013        if !record.detail.is_empty() {
1014            lines.push(format!("- **Detail:** {}", md_escape(&record.detail)));
1015        }
1016        if !record.reference.is_empty() {
1017            lines.push(format!("- **Reference:** {}", md_escape(&record.reference)));
1018        }
1019        if !record.raw_findings.is_empty() && record.raw_findings.len() > 1 {
1020            lines.push("- **Merged raw findings:**".to_string());
1021            for raw in &record.raw_findings {
1022                lines.push(format!("  - {}", md_escape(raw)));
1023            }
1024        }
1025        if !record.cves.is_empty() {
1026            lines.push(format!("- **CVEs:** {}", record.cves.join(", ")));
1027        }
1028        if record.raw != serde_json::Value::Null {
1029            lines.push("- **Raw JSON:**".to_string());
1030            lines.push("```json".to_string());
1031            lines.push(serde_json::to_string_pretty(&record.raw).unwrap_or_default());
1032            lines.push("```".to_string());
1033        }
1034        lines.push(String::new());
1035    }
1036
1037    if !recipes.is_empty() {
1038        lines.push("## Payload Command Recipes".to_string());
1039        lines.push(String::new());
1040        for recipe in recipes {
1041            lines.push(format!(
1042                "### {} / {}",
1043                title_case(&recipe.platform),
1044                title_case(&recipe.architecture)
1045            ));
1046            lines.push(String::new());
1047            lines.push(format!("- **LHOST:** {}", recipe.lhost));
1048            lines.push(format!("- **LPORT:** {}", recipe.lport));
1049            lines.push(format!("- **Format:** {}", recipe.format));
1050            lines.push(format!("- **Output:** {}", recipe.output_path));
1051            lines.push(String::new());
1052            lines.push("```bash".to_string());
1053            lines.push(recipe.command.clone());
1054            lines.push("```".to_string());
1055            lines.push(String::new());
1056        }
1057    }
1058
1059    lines.push("---".to_string());
1060    lines.push(format!(
1061        "*Report generated by PloitMalper v{}*",
1062        env!("CARGO_PKG_VERSION")
1063    ));
1064    lines.push(String::new());
1065
1066    let content = lines.join("\n");
1067    fs::write(output_path, content)?;
1068    Ok(PathBuf::from(output_path))
1069}
1070
1071// ---------------------------------------------------------------------------
1072// Backward-compatible wrappers
1073// ---------------------------------------------------------------------------
1074
1075pub fn render_findings_table(records: &[ScanRecord]) -> String {
1076    console_full_findings(&group_findings(records))
1077}
1078
1079pub fn render_module_table(suggestions: &[ModuleSuggestion]) -> String {
1080    let mut categories = categorize_modules(suggestions);
1081    categories.manual_notes = manual_exploitation_notes(&[]);
1082    console_module_categories(&categories)
1083}
1084
1085fn console_module_categories(categories: &ModuleCategories) -> String {
1086    let mut out = String::new();
1087    out.push_str("Metasploit Module Suggestions\n");
1088    if !categories.enumeration.is_empty() {
1089        out.push_str("Enumeration\n");
1090        for suggestion in &categories.enumeration {
1091            out.push_str(&format!(
1092                "✓ {} ({})\n",
1093                suggestion.suggested_module, suggestion.confidence
1094            ));
1095        }
1096    }
1097    if !categories.validation.is_empty() {
1098        out.push_str("Validation\n");
1099        for suggestion in &categories.validation {
1100            out.push_str(&format!(
1101                "✓ {} ({})\n",
1102                suggestion.suggested_module, suggestion.confidence
1103            ));
1104        }
1105    }
1106    if !categories.exploitation.is_empty() || !categories.manual_notes.is_empty() {
1107        out.push_str("Potential exploitation\n");
1108        for suggestion in &categories.exploitation {
1109            out.push_str(&format!(
1110                "✓ {} ({})\n",
1111                suggestion.suggested_module, suggestion.confidence
1112            ));
1113        }
1114        for note in &categories.manual_notes {
1115            out.push_str(&format!(
1116                "• {} (No generic Metasploit module available)\n",
1117                note
1118            ));
1119        }
1120    }
1121    out
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126    use super::*;
1127    use serde_json::json;
1128
1129    fn record(title: &str, severity: &str, url: &str) -> ScanRecord {
1130        ScanRecord {
1131            target: "testasp.vulnweb.com".into(),
1132            tool: "vulnmalper".into(),
1133            title: title.into(),
1134            severity: severity.into(),
1135            port: Some(80),
1136            cve: None,
1137            service: Some("Microsoft-IIS/8.5".into()),
1138            raw: json!({ "url": url }),
1139            ..Default::default()
1140        }
1141    }
1142
1143    #[test]
1144    fn console_report_contains_all_sections() {
1145        let records = vec![
1146            record(
1147                "LDAP Injection on QUERY_STRING",
1148                "critical",
1149                "http://h/search?q=",
1150            ),
1151            record(
1152                "LDAP Injection on QUERY_STRING",
1153                "critical",
1154                "http://h/login?user=",
1155            ),
1156            record("Trace.axd exposed", "medium", "http://h/trace.axd"),
1157        ];
1158        let suggestions = vec![ModuleSuggestion {
1159            service_banner: "IIS".into(),
1160            suggested_module: "auxiliary/scanner/http/iis_version".into(),
1161            confidence: "high".into(),
1162        }];
1163        let dedup_stats = DedupStats {
1164            total: 3,
1165            unique: 2,
1166            removed: 1,
1167        };
1168        let output = render_console_report(&records, &[], &suggestions, &[], &dedup_stats);
1169        assert!(output.contains("Executive Summary"));
1170        assert!(output.contains("Overall Risk"));
1171        assert!(output.contains("Key Findings"));
1172        assert!(output.contains("CVE Intelligence"));
1173        assert!(output.contains("Exploitability Assessment"));
1174        assert!(output.contains("Metasploit Recommendations"));
1175        assert!(output.contains("Attack Chain"));
1176        assert!(output.contains("Full Findings"));
1177        assert!(output.contains("Raw Evidence"));
1178        assert!(output.contains("LDAP Injection"));
1179        assert!(output.contains("Microsoft IIS"));
1180    }
1181
1182    #[test]
1183    fn no_credible_chain_rendered_without_evidence() {
1184        let records = vec![record("Alive (200)", "info", "http://h/")];
1185        let output = render_console_report(
1186            &records,
1187            &[],
1188            &[],
1189            &[],
1190            &DedupStats {
1191                total: 1,
1192                unique: 1,
1193                removed: 0,
1194            },
1195        );
1196        assert!(output.contains("No credible automated attack chain identified."));
1197    }
1198
1199    #[test]
1200    fn markdown_report_contains_sections_and_endpoints() {
1201        let records = vec![record(
1202            "LDAP Injection on QUERY_STRING",
1203            "critical",
1204            "http://h/search?q=",
1205        )];
1206        let dedup_stats = DedupStats {
1207            total: 1,
1208            unique: 1,
1209            removed: 0,
1210        };
1211        let path =
1212            generate_markdown_report(&records, &[], &[], &[], &dedup_stats, "/tmp/pm_test.md")
1213                .expect("write report");
1214        let content = std::fs::read_to_string(&path).expect("read report");
1215        assert!(content.contains("## 1. Executive Summary"));
1216        assert!(content.contains("## 2. Risk Overview"));
1217        assert!(content.contains("## 4. Injectable Endpoints"));
1218        assert!(content.contains("## 5. CVE Intelligence"));
1219        assert!(content.contains("## 6. Exploitability Assessment"));
1220        assert!(content.contains("## 7. Metasploit Recommendations"));
1221        assert!(content.contains("## 9. Attack Chain"));
1222        assert!(content.contains("## 11. Raw Evidence"));
1223        assert!(content.contains("/search?q="));
1224        let _ = std::fs::remove_file(path);
1225    }
1226}