Skip to main content

ploit_malper/
report.rs

1use std::fs;
2use std::path::{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
605/// Derive the markdown report output path from a scan JSON input path.
606/// `results/scan.json` becomes `results/scan_ploitmalper.md` in the same
607/// directory as the input.
608pub fn derive_report_path(input_path: &Path) -> PathBuf {
609    let stem = input_path
610        .file_stem()
611        .map(|s| s.to_string_lossy().to_string())
612        .filter(|s| !s.is_empty())
613        .unwrap_or_else(|| "report".to_string());
614    let directory = input_path
615        .parent()
616        .map(Path::to_path_buf)
617        .unwrap_or_else(|| PathBuf::from("."));
618    directory.join(format!("{}_ploitmalper.md", stem))
619}
620
621pub fn generate_markdown_report(
622    records: &[ScanRecord],
623    injectable: &[InjectableEndpoint],
624    _suggestions: &[ModuleSuggestion],
625    recipes: &[PayloadRecipe],
626    dedup_stats: &DedupStats,
627    output_path: &str,
628) -> Result<PathBuf> {
629    let mut lines = Vec::new();
630    let has_nvd = records.iter().any(|record| record.nvd_cvss_v3.is_some());
631
632    lines.push("# PloitMalper - Vulnerability Analysis Report".to_string());
633    lines.push(String::new());
634    lines.push(format!(
635        "**Generated:** {}",
636        SystemTime::now()
637            .duration_since(UNIX_EPOCH)
638            .map(|d| d.as_secs().to_string())
639            .unwrap_or_else(|_| "unknown".to_string())
640    ));
641    lines.push(format!("**Tool Version:** {}", env!("CARGO_PKG_VERSION")));
642    lines.push(String::new());
643
644    // 1. Executive Summary
645    let summary = build_scan_summary(records);
646    lines.push("## 1. Executive Summary".to_string());
647    lines.push(String::new());
648    lines.push(format!("- **Host:** {}", md_escape(&summary.host)));
649    if let Some(server) = &summary.server {
650        lines.push(format!("- **Server:** {}", md_escape(server)));
651    }
652    if let Some(framework) = &summary.framework {
653        lines.push(format!("- **Framework:** {}", md_escape(framework)));
654    }
655    lines.push(format!(
656        "- **Total records processed:** {}",
657        dedup_stats.total
658    ));
659    lines.push(format!("- **Unique findings:** {}", dedup_stats.unique));
660    lines.push(format!("- **Duplicates merged:** {}", dedup_stats.removed));
661    lines.push(format!("- **Injectable endpoints:** {}", injectable.len()));
662    lines.push(format!(
663        "- **CVE ids discovered:** {}",
664        unique_cves(records).len()
665    ));
666    if has_nvd {
667        lines.push("- **NVD enrichment:** Enabled".to_string());
668    }
669    lines.push(String::new());
670
671    lines.push("### Severity Distribution".to_string());
672    lines.push(String::new());
673    for severity in ["critical", "high", "medium", "low", "info", "unknown"] {
674        if let Some(count) = summary.severity_counts.get(severity) {
675            if *count > 0 {
676                lines.push(format!("- **{}:** {}", severity.to_uppercase(), count));
677            }
678        }
679    }
680    lines.push(String::new());
681
682    // 2. Risk Overview
683    let risk = compute_risk_score(records, injectable);
684    lines.push("## 2. Risk Overview".to_string());
685    lines.push(String::new());
686    lines.push(format!("### Overall Risk: {}/100", risk.score));
687    lines.push(String::new());
688    lines.push(format!("`{}`", risk.bar));
689    lines.push(String::new());
690    for reason in &risk.reasons {
691        lines.push(format!("- {}", reason));
692    }
693    lines.push(String::new());
694
695    // 3. Key Findings
696    let groups = group_findings(records);
697    lines.push("## 3. Key Findings".to_string());
698    lines.push(String::new());
699    let actionable: Vec<&FindingGroup> = groups
700        .iter()
701        .filter(|group| group.family != "Reconnaissance / Informational")
702        .collect();
703    let selection: Vec<&FindingGroup> = if actionable.is_empty() {
704        groups.iter().take(5).collect()
705    } else {
706        actionable.iter().take(6).cloned().collect()
707    };
708    if selection.is_empty() {
709        lines.push("No significant findings to highlight.".to_string());
710    } else {
711        for (idx, group) in selection.iter().enumerate() {
712            let cve_part = if group.cves.is_empty() {
713                String::new()
714            } else {
715                format!(" `{}`", group.cves.join(", "))
716            };
717            lines.push(format!(
718                "{}. **{}** — {} ({} finding{}){}",
719                idx + 1,
720                group.title,
721                group.severity.to_uppercase(),
722                group.count,
723                if group.count == 1 { "" } else { "s" },
724                cve_part
725            ));
726        }
727    }
728    lines.push(String::new());
729
730    // 4. Injectable Endpoints
731    lines.push("## 4. Injectable Endpoints".to_string());
732    lines.push(String::new());
733    if injectable.is_empty() {
734        lines.push("No injectable endpoints flagged by the scanner.".to_string());
735    } else {
736        lines.push(format!(
737            "{} potential injection point(s) reported:",
738            injectable.len()
739        ));
740        lines.push(String::new());
741        lines.push("| # | Endpoint | Affected Host |".to_string());
742        lines.push("|---|----------|---------------|".to_string());
743        for (idx, entry) in injectable.iter().enumerate() {
744            lines.push(format!(
745                "| {} | `{}` | {} |",
746                idx + 1,
747                md_escape(&entry.endpoint),
748                md_escape(&entry.target)
749            ));
750        }
751    }
752    lines.push(String::new());
753
754    // 5. CVE Intelligence
755    lines.push("## 5. CVE Intelligence".to_string());
756    lines.push(String::new());
757    let cves = unique_cves(records);
758    if cves.is_empty() {
759        lines.push("No CVEs were discovered in this scan.".to_string());
760    } else {
761        for cve in &cves {
762            let record = records.iter().find(|record| {
763                record
764                    .cves
765                    .iter()
766                    .any(|known| known.eq_ignore_ascii_case(cve))
767            });
768            lines.push(format!("### {}", cve));
769            lines.push(String::new());
770            if let Some(record) = record {
771                if let Some(cvss) = record.nvd_cvss_v3 {
772                    let severity = record
773                        .nvd_cvss_v3_severity
774                        .as_deref()
775                        .map(|s| format!(" ({})", s.to_uppercase()))
776                        .unwrap_or_default();
777                    lines.push(format!("- **CVSS v3 Score:** {:.1}{}", cvss, severity));
778                }
779                if let Some(cvss) = record.nvd_cvss_v2 {
780                    lines.push(format!("- **CVSS v2 Score:** {:.1}", cvss));
781                }
782                if !record.nvd_weaknesses.is_empty() {
783                    lines.push(format!("- **CWE:** {}", record.nvd_weaknesses.join(", ")));
784                }
785                if let Some(published) = record.nvd_published.as_deref() {
786                    if !published.is_empty() {
787                        lines.push(format!("- **Published:** {}", published));
788                    }
789                }
790                if let Some(description) = record.nvd_description.as_deref() {
791                    if !description.is_empty() {
792                        lines.push(format!("- **Description:** {}", description));
793                    }
794                }
795            }
796            if let Some(extra) = enrich::enrich_cve(cve) {
797                lines.push(format!("- **EPSS:** {}", enrich::epss_percent(extra.epss)));
798                lines.push(format!(
799                    "- **Known Exploited (CISA KEV):** {}",
800                    if extra.in_kev { "YES" } else { "NO" }
801                ));
802                lines.push(format!(
803                    "- **Public exploit:** {}",
804                    if extra.public_exploit { "YES" } else { "NO" }
805                ));
806            }
807            if let Some(record) = record {
808                if !record.nvd_references.is_empty() {
809                    lines.push("- **References:**".to_string());
810                    for reference in &record.nvd_references {
811                        lines.push(format!("  - {}", reference));
812                    }
813                }
814            }
815            lines.push(String::new());
816        }
817    }
818
819    // 6. Exploitability Assessment
820    let assessment = exploitability_assessment(records, injectable);
821    lines.push("## 6. Exploitability Assessment".to_string());
822    lines.push(String::new());
823    lines.push(format!(
824        "**Rating:** {} ({}/100)",
825        assessment.rating, assessment.score
826    ));
827    lines.push(String::new());
828    for reason in &assessment.reasons {
829        lines.push(format!("- {}", reason));
830    }
831    lines.push(String::new());
832
833    // 7. Metasploit Recommendations
834    lines.push("## 7. Metasploit Recommendations".to_string());
835    lines.push(String::new());
836    let mut msf_any = false;
837    for cve in &cves {
838        let modules = crate::msf::modules_for_cve(cve);
839        if modules.is_empty() {
840            let reason = crate::msf::no_module_reason(cve);
841            lines.push(format!("**{}** — {}", cve, reason.reason));
842            lines.push(String::new());
843            msf_any = true;
844            continue;
845        }
846        msf_any = true;
847        lines.push(format!("### {}", cve));
848        lines.push(String::new());
849        for module in &modules {
850            lines.push(format!("- **Module:** `{}`", module.name));
851            lines.push(format!("  - **Rank:** {}", module.rank));
852            lines.push(format!("  - **Disclosure:** {}", module.disclosure_date));
853            lines.push(format!(
854                "  - **Platforms:** {}",
855                if module.platforms.is_empty() {
856                    "any".to_string()
857                } else {
858                    module.platforms.join(", ")
859                }
860            ));
861            lines.push(format!(
862                "  - **Required options:** {}",
863                if module.required_options.is_empty() {
864                    "none".to_string()
865                } else {
866                    module.required_options.join(", ")
867                }
868            ));
869        }
870        lines.push(String::new());
871    }
872    let tech = technologies(records);
873    if !tech.is_empty() {
874        let modules = crate::msf::modules_for_technology(&tech);
875        if !modules.is_empty() {
876            lines.push("### Technology-driven".to_string());
877            lines.push(String::new());
878            for module in &modules {
879                lines.push(format!(
880                    "- `{}` (rank: {}, disclosure: {})",
881                    module.name, module.rank, module.disclosure_date
882                ));
883            }
884            lines.push(String::new());
885            msf_any = true;
886        }
887    }
888    if !injectable.is_empty() {
889        lines.push(
890            "- **Injectable endpoints:** no Metasploit module can validate an application"
891                .to_string(),
892        );
893        lines.push(
894            "  injection point automatically — manual testing is required (see Attack Chain)."
895                .to_string(),
896        );
897        lines.push(String::new());
898        msf_any = true;
899    }
900    if !msf_any {
901        lines.push("No CVEs or recognized technologies warrant a Metasploit module.".to_string());
902        lines.push(String::new());
903    }
904
905    // 8. MITRE ATT&CK Mapping
906    let tactics = map_mitre(records);
907    lines.push("## 8. MITRE ATT&CK Mapping".to_string());
908    lines.push(String::new());
909    if tactics.is_empty() {
910        lines.push("No applicable tactics.".to_string());
911    } else {
912        for tactic in &tactics {
913            lines.push(format!("### {}", tactic.tactic));
914            lines.push(String::new());
915            for technique in &tactic.techniques {
916                lines.push(format!("- ✓ {}", technique));
917            }
918            lines.push(String::new());
919        }
920    }
921
922    // 9. Attack Chain
923    lines.push("## 9. Attack Chain".to_string());
924    lines.push(String::new());
925    match build_attack_chain(records, injectable) {
926        Some(steps) => {
927            lines.push("```text".to_string());
928            for (idx, step) in steps.iter().enumerate() {
929                if idx > 0 {
930                    lines.push("     │".to_string());
931                    lines.push("     ▼".to_string());
932                }
933                lines.push(step.clone());
934            }
935            lines.push("```".to_string());
936        }
937        None => {
938            lines.push("**No credible automated attack chain identified.**".to_string());
939            lines.push(String::new());
940            lines.push(
941                "No injectable endpoints or known-exploitable CVEs were found in this scan; chaining steps without evidence would be speculative.".to_string(),
942            );
943        }
944    }
945    lines.push(String::new());
946
947    // 10. Full Findings
948    lines.push("## 10. Full Findings".to_string());
949    lines.push(String::new());
950    lines.push("| # | Finding | Severity | Count | Endpoints | CVE |".to_string());
951    lines.push("|---|---------|----------|-------|-----------|-----|".to_string());
952    for (idx, group) in groups.iter().enumerate() {
953        lines.push(format!(
954            "| {} | {} | {} | {} | {} | {} |",
955            idx + 1,
956            md_escape(&group.title),
957            group.severity.to_uppercase(),
958            group.count,
959            if group.affected_endpoints.is_empty() {
960                "—".to_string()
961            } else {
962                group.affected_endpoints.len().to_string()
963            },
964            if group.cves.is_empty() {
965                "—".to_string()
966            } else {
967                group.cves.join(", ")
968            }
969        ));
970    }
971    lines.push(String::new());
972    for group in &groups {
973        lines.push(format!(
974            "### {} — {}",
975            group.title,
976            group.severity.to_uppercase()
977        ));
978        lines.push(String::new());
979        lines.push(format!("- **Count:** {}", group.count));
980        lines.push(format!("- **Target:** {}", md_escape(&group.target)));
981        if !group.ports.is_empty() {
982            lines.push(format!("- **Ports:** {}", group.ports.join(", ")));
983        }
984        if !group.tools.is_empty() {
985            lines.push(format!("- **Tools:** {}", group.tools.join(", ")));
986        }
987        if !group.cves.is_empty() {
988            lines.push(format!("- **CVEs:** {}", group.cves.join(", ")));
989        }
990        if !group.details.is_empty() {
991            lines.push("- **Details:**".to_string());
992            for detail in &group.details {
993                lines.push(format!("  - {}", md_escape(detail)));
994            }
995        }
996        if !group.affected_endpoints.is_empty() {
997            lines.push("- **Affected endpoints:**".to_string());
998            for endpoint in &group.affected_endpoints {
999                lines.push(format!("  - `{}`", md_escape(endpoint)));
1000            }
1001        }
1002        if !group.evidence.is_empty() {
1003            lines.push("- **Scanner evidence:**".to_string());
1004            for evidence in &group.evidence {
1005                lines.push(format!("  - {}", md_escape(evidence)));
1006            }
1007        }
1008        lines.push(String::new());
1009    }
1010
1011    // 11. Raw Evidence
1012    lines.push("## 11. Raw Evidence".to_string());
1013    lines.push(String::new());
1014    lines.push(
1015        "Every scanner finding below maps back to the original VulnMalper output for full traceability.".to_string(),
1016    );
1017    lines.push(String::new());
1018    for (idx, record) in records.iter().enumerate() {
1019        lines.push(format!("### {}. {}", idx + 1, md_escape(&record.title)));
1020        lines.push(String::new());
1021        lines.push(format!("- **Tool:** {}", record.tool));
1022        lines.push(format!("- **Target:** {}", md_escape(&record.target)));
1023        if !record.normalized_title.is_empty() {
1024            lines.push(format!(
1025                "- **Normalized:** `{}`",
1026                md_escape(&record.normalized_title)
1027            ));
1028        }
1029        if !record.detail.is_empty() {
1030            lines.push(format!("- **Detail:** {}", md_escape(&record.detail)));
1031        }
1032        if !record.reference.is_empty() {
1033            lines.push(format!("- **Reference:** {}", md_escape(&record.reference)));
1034        }
1035        if !record.raw_findings.is_empty() && record.raw_findings.len() > 1 {
1036            lines.push("- **Merged raw findings:**".to_string());
1037            for raw in &record.raw_findings {
1038                lines.push(format!("  - {}", md_escape(raw)));
1039            }
1040        }
1041        if !record.cves.is_empty() {
1042            lines.push(format!("- **CVEs:** {}", record.cves.join(", ")));
1043        }
1044        if record.raw != serde_json::Value::Null {
1045            lines.push("- **Raw JSON:**".to_string());
1046            lines.push("```json".to_string());
1047            lines.push(serde_json::to_string_pretty(&record.raw).unwrap_or_default());
1048            lines.push("```".to_string());
1049        }
1050        lines.push(String::new());
1051    }
1052
1053    if !recipes.is_empty() {
1054        lines.push("## Payload Command Recipes".to_string());
1055        lines.push(String::new());
1056        for recipe in recipes {
1057            lines.push(format!(
1058                "### {} / {}",
1059                title_case(&recipe.platform),
1060                title_case(&recipe.architecture)
1061            ));
1062            lines.push(String::new());
1063            lines.push(format!("- **LHOST:** {}", recipe.lhost));
1064            lines.push(format!("- **LPORT:** {}", recipe.lport));
1065            lines.push(format!("- **Format:** {}", recipe.format));
1066            lines.push(format!("- **Output:** {}", recipe.output_path));
1067            lines.push(String::new());
1068            lines.push("```bash".to_string());
1069            lines.push(recipe.command.clone());
1070            lines.push("```".to_string());
1071            lines.push(String::new());
1072        }
1073    }
1074
1075    lines.push("---".to_string());
1076    lines.push(format!(
1077        "*Report generated by PloitMalper v{}*",
1078        env!("CARGO_PKG_VERSION")
1079    ));
1080    lines.push(String::new());
1081
1082    let content = lines.join("\n");
1083    fs::write(output_path, content)?;
1084    Ok(PathBuf::from(output_path))
1085}
1086
1087// ---------------------------------------------------------------------------
1088// Backward-compatible wrappers
1089// ---------------------------------------------------------------------------
1090
1091pub fn render_findings_table(records: &[ScanRecord]) -> String {
1092    console_full_findings(&group_findings(records))
1093}
1094
1095pub fn render_module_table(suggestions: &[ModuleSuggestion]) -> String {
1096    let mut categories = categorize_modules(suggestions);
1097    categories.manual_notes = manual_exploitation_notes(&[]);
1098    console_module_categories(&categories)
1099}
1100
1101fn console_module_categories(categories: &ModuleCategories) -> String {
1102    let mut out = String::new();
1103    out.push_str("Metasploit Module Suggestions\n");
1104    if !categories.enumeration.is_empty() {
1105        out.push_str("Enumeration\n");
1106        for suggestion in &categories.enumeration {
1107            out.push_str(&format!(
1108                "✓ {} ({})\n",
1109                suggestion.suggested_module, suggestion.confidence
1110            ));
1111        }
1112    }
1113    if !categories.validation.is_empty() {
1114        out.push_str("Validation\n");
1115        for suggestion in &categories.validation {
1116            out.push_str(&format!(
1117                "✓ {} ({})\n",
1118                suggestion.suggested_module, suggestion.confidence
1119            ));
1120        }
1121    }
1122    if !categories.exploitation.is_empty() || !categories.manual_notes.is_empty() {
1123        out.push_str("Potential exploitation\n");
1124        for suggestion in &categories.exploitation {
1125            out.push_str(&format!(
1126                "✓ {} ({})\n",
1127                suggestion.suggested_module, suggestion.confidence
1128            ));
1129        }
1130        for note in &categories.manual_notes {
1131            out.push_str(&format!(
1132                "• {} (No generic Metasploit module available)\n",
1133                note
1134            ));
1135        }
1136    }
1137    out
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142    use super::*;
1143    use serde_json::json;
1144
1145    fn record(title: &str, severity: &str, url: &str) -> ScanRecord {
1146        ScanRecord {
1147            target: "testasp.vulnweb.com".into(),
1148            tool: "vulnmalper".into(),
1149            title: title.into(),
1150            severity: severity.into(),
1151            port: Some(80),
1152            cve: None,
1153            service: Some("Microsoft-IIS/8.5".into()),
1154            raw: json!({ "url": url }),
1155            ..Default::default()
1156        }
1157    }
1158
1159    #[test]
1160    fn console_report_contains_all_sections() {
1161        let records = vec![
1162            record(
1163                "LDAP Injection on QUERY_STRING",
1164                "critical",
1165                "http://h/search?q=",
1166            ),
1167            record(
1168                "LDAP Injection on QUERY_STRING",
1169                "critical",
1170                "http://h/login?user=",
1171            ),
1172            record("Trace.axd exposed", "medium", "http://h/trace.axd"),
1173        ];
1174        let suggestions = vec![ModuleSuggestion {
1175            service_banner: "IIS".into(),
1176            suggested_module: "auxiliary/scanner/http/iis_version".into(),
1177            confidence: "high".into(),
1178        }];
1179        let dedup_stats = DedupStats {
1180            total: 3,
1181            unique: 2,
1182            removed: 1,
1183        };
1184        let output = render_console_report(&records, &[], &suggestions, &[], &dedup_stats);
1185        assert!(output.contains("Executive Summary"));
1186        assert!(output.contains("Overall Risk"));
1187        assert!(output.contains("Key Findings"));
1188        assert!(output.contains("CVE Intelligence"));
1189        assert!(output.contains("Exploitability Assessment"));
1190        assert!(output.contains("Metasploit Recommendations"));
1191        assert!(output.contains("Attack Chain"));
1192        assert!(output.contains("Full Findings"));
1193        assert!(output.contains("Raw Evidence"));
1194        assert!(output.contains("LDAP Injection"));
1195        assert!(output.contains("Microsoft IIS"));
1196    }
1197
1198    #[test]
1199    fn no_credible_chain_rendered_without_evidence() {
1200        let records = vec![record("Alive (200)", "info", "http://h/")];
1201        let output = render_console_report(
1202            &records,
1203            &[],
1204            &[],
1205            &[],
1206            &DedupStats {
1207                total: 1,
1208                unique: 1,
1209                removed: 0,
1210            },
1211        );
1212        assert!(output.contains("No credible automated attack chain identified."));
1213    }
1214
1215    #[test]
1216    fn markdown_report_contains_sections_and_endpoints() {
1217        let records = vec![record(
1218            "LDAP Injection on QUERY_STRING",
1219            "critical",
1220            "http://h/search?q=",
1221        )];
1222        let dedup_stats = DedupStats {
1223            total: 1,
1224            unique: 1,
1225            removed: 0,
1226        };
1227        let path =
1228            generate_markdown_report(&records, &[], &[], &[], &dedup_stats, "/tmp/pm_test.md")
1229                .expect("write report");
1230        let content = std::fs::read_to_string(&path).expect("read report");
1231        assert!(content.contains("## 1. Executive Summary"));
1232        assert!(content.contains("## 2. Risk Overview"));
1233        assert!(content.contains("## 4. Injectable Endpoints"));
1234        assert!(content.contains("## 5. CVE Intelligence"));
1235        assert!(content.contains("## 6. Exploitability Assessment"));
1236        assert!(content.contains("## 7. Metasploit Recommendations"));
1237        assert!(content.contains("## 9. Attack Chain"));
1238        assert!(content.contains("## 11. Raw Evidence"));
1239        assert!(content.contains("/search?q="));
1240        assert!(content.contains("# PloitMalper - Vulnerability Analysis Report"));
1241        let _ = std::fs::remove_file(path);
1242    }
1243
1244    #[test]
1245    fn report_path_is_derived_from_input() {
1246        assert_eq!(
1247            derive_report_path(&PathBuf::from("results/scan.json")),
1248            PathBuf::from("results/scan_ploitmalper.md")
1249        );
1250        assert_eq!(
1251            derive_report_path(&PathBuf::from("/tmp/data/scan.json")),
1252            PathBuf::from("/tmp/data/scan_ploitmalper.md")
1253        );
1254        assert_eq!(
1255            derive_report_path(&PathBuf::from("scan.json")),
1256            PathBuf::from("scan_ploitmalper.md")
1257        );
1258    }
1259}