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
129fn 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
579fn 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 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 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 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 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 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 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 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 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 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 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 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 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
1087pub 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}