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
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 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 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 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 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 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 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 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 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 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 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 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 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
1071pub 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}