Skip to main content

ploit_malper/
report.rs

1use std::fs;
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::analyze::{
6    build_attack_path, build_scan_summary, categorize_modules, compute_risk_score, group_findings,
7    manual_exploitation_notes, map_mitre,
8};
9use crate::enrich;
10use crate::error::Result;
11use crate::models::{
12    CVEExtraInfo, DedupStats, FindingGroup, ModuleCategories, ModuleSuggestion, PayloadRecipe,
13    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
87// ---------------------------------------------------------------------------
88// Console rendering
89// ---------------------------------------------------------------------------
90
91pub fn render_scan_summary(summary: &ScanSummary) -> String {
92    let mut out = String::new();
93    out.push_str("Scan Summary\n");
94    out.push_str("────────────\n");
95    out.push('\n');
96    out.push_str(&format!("Host: {}\n", summary.host));
97    if let Some(server) = &summary.server {
98        out.push_str(&format!("Server: {}\n", server));
99    }
100    if let Some(framework) = &summary.framework {
101        out.push_str(&format!("Framework: {}\n", framework));
102    }
103    out.push('\n');
104
105    for severity in ["critical", "high", "medium", "low", "info"] {
106        if let Some(count) = summary.severity_counts.get(severity) {
107            if *count > 0 {
108                out.push_str(&format!(
109                    "{:<9}: {}\n",
110                    format!("{}", severity.to_uppercase()),
111                    count
112                ));
113            }
114        }
115    }
116
117    if !summary.top_risks.is_empty() {
118        out.push('\n');
119        out.push_str("Top Risks\n");
120        for risk in &summary.top_risks {
121            out.push_str(&format!("✓ {}\n", risk));
122        }
123    }
124    out
125}
126
127pub fn render_risk_score(records: &[ScanRecord]) -> String {
128    let risk = compute_risk_score(records);
129    let mut out = String::new();
130    out.push_str(&format!("Overall Risk: {}/100\n", risk.score));
131    out.push_str(&risk.bar);
132    out.push_str("\n\nReason:\n");
133    for reason in &risk.reasons {
134        out.push_str(&format!("• {}\n", reason));
135    }
136    out
137}
138
139pub fn render_finding_groups(groups: &[FindingGroup]) -> String {
140    let mut out = String::new();
141
142    let rows = groups
143        .iter()
144        .enumerate()
145        .map(|(idx, group)| {
146            vec![
147                (idx + 1).to_string(),
148                truncate(&group.title, 34),
149                group.severity.to_uppercase(),
150                group.count.to_string(),
151                if group.affected_endpoints.is_empty() {
152                    "—".to_string()
153                } else {
154                    group.affected_endpoints.len().to_string()
155                },
156                if group.cves.is_empty() {
157                    "—".to_string()
158                } else {
159                    group.cves.join(", ")
160                },
161            ]
162        })
163        .collect::<Vec<_>>();
164
165    if !rows.is_empty() {
166        out.push_str(&ascii_table(
167            "Deduplicated Vulnerability Findings",
168            &["#", "Finding", "Severity", "Count", "Endpoints", "CVE"],
169            &rows,
170        ));
171        out.push('\n');
172    }
173
174    let groups_with_endpoints = groups
175        .iter()
176        .filter(|group| !group.affected_endpoints.is_empty())
177        .collect::<Vec<_>>();
178    if !groups_with_endpoints.is_empty() {
179        out.push_str("Affected endpoints\n");
180        for (idx, group) in groups_with_endpoints.iter().enumerate() {
181            out.push_str(&format!("  {}. {}\n", idx + 1, group.title));
182            for endpoint in &group.affected_endpoints {
183                out.push_str(&format!("     • {}\n", endpoint));
184            }
185        }
186    }
187    out
188}
189
190pub fn render_module_categories(categories: &ModuleCategories) -> String {
191    let mut out = String::new();
192    out.push_str("Metasploit Module Suggestions\n");
193
194    if !categories.enumeration.is_empty() {
195        out.push_str("Enumeration\n");
196        out.push_str("------------\n");
197        for suggestion in &categories.enumeration {
198            out.push_str(&format!(
199                "✓ {} ({})\n",
200                suggestion.suggested_module, suggestion.confidence
201            ));
202        }
203        out.push('\n');
204    }
205
206    if !categories.validation.is_empty() {
207        out.push_str("Validation\n");
208        out.push_str("----------\n");
209        for suggestion in &categories.validation {
210            out.push_str(&format!(
211                "✓ {} ({})\n",
212                suggestion.suggested_module, suggestion.confidence
213            ));
214        }
215        out.push('\n');
216    }
217
218    if !categories.exploitation.is_empty() || !categories.manual_notes.is_empty() {
219        out.push_str("Potential exploitation\n");
220        out.push_str("----------------------\n");
221        for suggestion in &categories.exploitation {
222            out.push_str(&format!(
223                "✓ {} ({})\n",
224                suggestion.suggested_module, suggestion.confidence
225            ));
226        }
227        for note in &categories.manual_notes {
228            out.push_str(&format!(
229                "• {}\n  (No generic Metasploit module available)\n",
230                note
231            ));
232        }
233        out.push('\n');
234    }
235
236    if out.trim_end().is_empty() {
237        out.push_str("(none)\n");
238    }
239    out
240}
241
242pub fn render_attack_path(records: &[ScanRecord]) -> String {
243    let steps = build_attack_path(records);
244    let mut out = String::new();
245    out.push_str("Possible Attack Chain\n");
246    for (idx, step) in steps.iter().enumerate() {
247        if idx > 0 {
248            out.push_str("     │\n     ▼\n");
249        }
250        out.push_str(&format!("{}\n", step));
251    }
252    out
253}
254
255pub fn render_mitre(records: &[ScanRecord]) -> String {
256    let tactics = map_mitre(records);
257    let mut out = String::new();
258    out.push_str("MITRE ATT&CK Mapping\n");
259    if tactics.is_empty() {
260        out.push_str("(no applicable tactics)\n");
261        return out;
262    }
263    for tactic in &tactics {
264        out.push_str(&tactic.tactic);
265        out.push('\n');
266        out.push_str(&"─".repeat(tactic.tactic.chars().count()));
267        out.push('\n');
268        for technique in &tactic.techniques {
269            out.push_str(&format!("✓ {}\n", technique));
270        }
271        if tactic.tactic == "Credential Access"
272            && records.iter().any(|record| {
273                let text = record.title.to_lowercase();
274                text.contains("sql injection") || text.contains("ldap injection")
275            })
276        {
277            out.push_str("Possible SQL/LDAP credential extraction\n");
278        }
279        out.push('\n');
280    }
281    out
282}
283
284fn render_cve_block(record: &ScanRecord, extra: Option<&CVEExtraInfo>) -> Option<String> {
285    let cve = record.cve.as_deref()?;
286    let mut lines = Vec::new();
287    lines.push(cve.to_string());
288    if let Some(cvss) = record.nvd_cvss_v3 {
289        lines.push(format!("  CVSS: {:.1}", cvss));
290    }
291    if let Some(extra) = extra {
292        lines.push(format!("  EPSS: {}", enrich::epss_percent(extra.epss)));
293        lines.push(format!(
294            "  Known Exploited: {}",
295            if extra.in_kev { "YES" } else { "NO" }
296        ));
297        lines.push(format!(
298            "  Public exploit: {}",
299            if extra.public_exploit { "YES" } else { "NO" }
300        ));
301        if !extra.metasploit_modules.is_empty() {
302            lines.push("  Metasploit:".to_string());
303            for module in &extra.metasploit_modules {
304                lines.push(format!("    {}", module));
305            }
306        }
307    }
308    if let Some(description) = record.nvd_description.as_deref() {
309        if !description.is_empty() {
310            lines.push(format!("  Description: {}", truncate(description, 100)));
311        }
312    }
313    Some(lines.join("\n"))
314}
315
316pub fn render_cve_enrichment(records: &[ScanRecord]) -> String {
317    let mut out = String::new();
318    out.push_str("CVE Enrichment\n");
319    let mut rendered = 0usize;
320    for record in records {
321        if record.cve.is_none() {
322            continue;
323        }
324        let extra = record.cve.as_deref().and_then(enrich::enrich_cve);
325        if let Some(block) = render_cve_block(record, extra.as_ref()) {
326            out.push_str(&block);
327            out.push('\n');
328            rendered += 1;
329        }
330    }
331    if rendered == 0 {
332        out.push_str("(no CVEs in findings)\n");
333    }
334    out
335}
336
337fn render_recipes(recipes: &[PayloadRecipe]) -> String {
338    let mut out = String::new();
339    out.push_str("Payload Command Recipes\n");
340    for recipe in recipes {
341        out.push_str(&format!(
342            "{} / {}\n  LHOST: {}\n  LPORT: {}\n  Format: {}\n  Output: {}\n\n  {}\n\n",
343            recipe.platform,
344            recipe.architecture,
345            recipe.lhost,
346            recipe.lport,
347            recipe.format,
348            recipe.output_path,
349            recipe.command
350        ));
351    }
352    out
353}
354
355pub fn render_console_report(
356    records: &[ScanRecord],
357    suggestions: &[ModuleSuggestion],
358    recipes: &[PayloadRecipe],
359) -> String {
360    let summary = build_scan_summary(records);
361    let groups = group_findings(records);
362    let mut categories = categorize_modules(suggestions);
363    categories.manual_notes = manual_exploitation_notes(records);
364
365    let mut out = String::new();
366    out.push_str(&render_scan_summary(&summary));
367    out.push('\n');
368    out.push_str(&render_risk_score(records));
369    out.push('\n');
370    out.push_str(&render_finding_groups(&groups));
371    out.push('\n');
372    out.push_str(&render_module_categories(&categories));
373    out.push('\n');
374    out.push_str(&render_attack_path(records));
375    out.push('\n');
376    out.push_str(&render_mitre(records));
377    out.push('\n');
378    out.push_str(&render_cve_enrichment(records));
379    if !recipes.is_empty() {
380        out.push('\n');
381        out.push_str(&render_recipes(recipes));
382    }
383    out
384}
385
386// ---------------------------------------------------------------------------
387// Markdown report
388// ---------------------------------------------------------------------------
389
390fn md_escape(value: &str) -> String {
391    value.replace('|', r"\|")
392}
393
394fn title_case(value: &str) -> String {
395    value
396        .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
397        .filter(|segment| !segment.is_empty())
398        .map(|segment| {
399            let mut chars = segment.chars();
400            match chars.next() {
401                Some(first) => {
402                    let first = first.to_uppercase().collect::<String>();
403                    format!("{}{}", first, chars.as_str().to_lowercase())
404                }
405                None => String::new(),
406            }
407        })
408        .collect::<Vec<_>>()
409        .join(" ")
410}
411
412pub fn generate_markdown_report(
413    records: &[ScanRecord],
414    suggestions: &[ModuleSuggestion],
415    recipes: &[PayloadRecipe],
416    dedup_stats: &DedupStats,
417    output_path: &str,
418) -> Result<PathBuf> {
419    let mut lines = Vec::new();
420    let has_nvd = records.iter().any(|record| record.nvd_cvss_v3.is_some());
421
422    lines.push("# PloitMalper - Vulnerability Analysis Report".to_string());
423    lines.push(String::new());
424    lines.push(format!(
425        "**Generated:** {}",
426        SystemTime::now()
427            .duration_since(UNIX_EPOCH)
428            .map(|d| d.as_secs().to_string())
429            .unwrap_or_else(|_| "unknown".to_string())
430    ));
431    lines.push(format!("**Tool Version:** {}", env!("CARGO_PKG_VERSION")));
432    lines.push(String::new());
433
434    // Executive summary
435    let summary = build_scan_summary(records);
436    let risk = compute_risk_score(records);
437
438    lines.push("## Executive Summary".to_string());
439    lines.push(String::new());
440    lines.push(format!("- **Host:** {}", md_escape(&summary.host)));
441    if let Some(server) = &summary.server {
442        lines.push(format!("- **Server:** {}", md_escape(server)));
443    }
444    if let Some(framework) = &summary.framework {
445        lines.push(format!("- **Framework:** {}", md_escape(framework)));
446    }
447    lines.push(format!(
448        "- **Total records processed:** {}",
449        dedup_stats.total
450    ));
451    lines.push(format!("- **Unique findings:** {}", dedup_stats.unique));
452    lines.push(format!("- **Duplicates removed:** {}", dedup_stats.removed));
453    if has_nvd {
454        lines.push("- **NVD enrichment:** Enabled".to_string());
455    }
456    lines.push(String::new());
457
458    lines.push(format!("## Overall Risk: {}/100", risk.score));
459    lines.push(String::new());
460    lines.push(format!("`{}`", risk.bar));
461    lines.push(String::new());
462    for reason in &risk.reasons {
463        lines.push(format!("- {}", reason));
464    }
465    lines.push(String::new());
466
467    lines.push("### Severity Distribution".to_string());
468    lines.push(String::new());
469    for severity in ["critical", "high", "medium", "low", "info", "unknown"] {
470        if let Some(count) = summary.severity_counts.get(severity) {
471            if *count > 0 {
472                lines.push(format!("- **{}:** {}", severity.to_uppercase(), count));
473            }
474        }
475    }
476    lines.push(String::new());
477
478    if !summary.top_risks.is_empty() {
479        lines.push("### Top Risks".to_string());
480        lines.push(String::new());
481        for risk in &summary.top_risks {
482            lines.push(format!("- **✓** {}", risk));
483        }
484        lines.push(String::new());
485    }
486
487    // Findings with affected endpoints
488    let groups = group_findings(records);
489    lines.push("## Findings".to_string());
490    lines.push(String::new());
491    lines.push("| # | Finding | Severity | Count | Endpoints | CVE |".to_string());
492    lines.push("|---|---------|----------|-------|-----------|-----|".to_string());
493    for (idx, group) in groups.iter().enumerate() {
494        lines.push(format!(
495            "| {} | {} | {} | {} | {} | {} |",
496            idx + 1,
497            md_escape(&group.title),
498            group.severity.to_uppercase(),
499            group.count,
500            if group.affected_endpoints.is_empty() {
501                "—".to_string()
502            } else {
503                group.affected_endpoints.len().to_string()
504            },
505            if group.cves.is_empty() {
506                "—".to_string()
507            } else {
508                group.cves.join(", ")
509            }
510        ));
511    }
512    lines.push(String::new());
513
514    let groups_with_endpoints = groups
515        .iter()
516        .filter(|group| !group.affected_endpoints.is_empty())
517        .collect::<Vec<_>>();
518    if !groups_with_endpoints.is_empty() {
519        lines.push("### Affected endpoints".to_string());
520        lines.push(String::new());
521        for (idx, group) in groups_with_endpoints.iter().enumerate() {
522            lines.push(format!("**{}. {}**", idx + 1, group.title));
523            lines.push(String::new());
524            for endpoint in &group.affected_endpoints {
525                lines.push(format!("- `{}`", md_escape(endpoint)));
526            }
527            lines.push(String::new());
528        }
529    }
530
531    // Attack chain
532    let path = build_attack_path(records);
533    if !path.is_empty() {
534        lines.push("## Possible Attack Chain".to_string());
535        lines.push(String::new());
536        lines.push("```text".to_string());
537        for (idx, step) in path.iter().enumerate() {
538            if idx > 0 {
539                lines.push("     │".to_string());
540                lines.push("     ▼".to_string());
541            }
542            lines.push(step.clone());
543        }
544        lines.push("```".to_string());
545        lines.push(String::new());
546    }
547
548    // MITRE ATT&CK
549    let tactics = map_mitre(records);
550    if !tactics.is_empty() {
551        lines.push("## MITRE ATT&CK Mapping".to_string());
552        lines.push(String::new());
553        for tactic in &tactics {
554            lines.push(format!("### {}", tactic.tactic));
555            lines.push(String::new());
556            for technique in &tactic.techniques {
557                lines.push(format!("- ✓ {}", technique));
558            }
559            if tactic.tactic == "Credential Access"
560                && records.iter().any(|record| {
561                    record.title.to_lowercase().contains("sql injection")
562                        || record.title.to_lowercase().contains("ldap injection")
563                })
564            {
565                lines.push("- Possible SQL/LDAP credential extraction".to_string());
566            }
567            lines.push(String::new());
568        }
569    }
570
571    // Metasploit module suggestions by category
572    if !suggestions.is_empty() {
573        let mut categories = categorize_modules(suggestions);
574        categories.manual_notes = manual_exploitation_notes(records);
575        lines.push("## Metasploit Module Suggestions".to_string());
576        lines.push(String::new());
577
578        if !categories.enumeration.is_empty() {
579            lines.push("### Enumeration".to_string());
580            lines.push(String::new());
581            for suggestion in &categories.enumeration {
582                lines.push(format!(
583                    "- `{}` ({})",
584                    suggestion.suggested_module, suggestion.confidence
585                ));
586            }
587            lines.push(String::new());
588        }
589
590        if !categories.validation.is_empty() {
591            lines.push("### Validation".to_string());
592            lines.push(String::new());
593            for suggestion in &categories.validation {
594                lines.push(format!(
595                    "- `{}` ({})",
596                    suggestion.suggested_module, suggestion.confidence
597                ));
598            }
599            lines.push(String::new());
600        }
601
602        if !categories.exploitation.is_empty() || !categories.manual_notes.is_empty() {
603            lines.push("### Potential Exploitation".to_string());
604            lines.push(String::new());
605            for suggestion in &categories.exploitation {
606                lines.push(format!(
607                    "- `{}` ({})",
608                    suggestion.suggested_module, suggestion.confidence
609                ));
610            }
611            for note in &categories.manual_notes {
612                lines.push(format!(
613                    "- {} (No generic Metasploit module available)",
614                    note
615                ));
616            }
617            lines.push(String::new());
618        }
619    }
620
621    // CVE details
622    if has_nvd || records.iter().any(|record| record.cve.is_some()) {
623        lines.push("## CVE Details".to_string());
624        lines.push(String::new());
625        for record in records {
626            let cve = match record.cve.as_ref() {
627                Some(cve) => cve,
628                None => continue,
629            };
630            let extra = enrich::enrich_cve(cve);
631
632            lines.push(format!("### {}", cve));
633            lines.push(String::new());
634            if let Some(cvss) = record.nvd_cvss_v3 {
635                lines.push(format!("- **CVSS v3 Score:** {:.1}", cvss));
636            }
637            if let Some(severity) = record.nvd_cvss_v3_severity.as_ref() {
638                lines.push(format!("- **NVD Severity:** {}", severity.to_uppercase()));
639            }
640            if let Some(extra) = &extra {
641                lines.push(format!("- **EPSS:** {}", enrich::epss_percent(extra.epss)));
642                lines.push(format!(
643                    "- **Known Exploited (CISA KEV):** {}",
644                    if extra.in_kev { "YES" } else { "NO" }
645                ));
646                lines.push(format!(
647                    "- **Public exploit:** {}",
648                    if extra.public_exploit { "YES" } else { "NO" }
649                ));
650                if !extra.metasploit_modules.is_empty() {
651                    lines.push("- **Metasploit:**".to_string());
652                    for module in &extra.metasploit_modules {
653                        lines.push(format!("  - `{}`", module));
654                    }
655                }
656            }
657            if let Some(published) = record.nvd_published.as_ref() {
658                lines.push(format!("- **Published:** {}", published));
659            }
660            if let Some(description) = record.nvd_description.as_ref() {
661                if !description.is_empty() {
662                    lines.push(format!("- **Description:** {}", description));
663                }
664            }
665            if !record.nvd_references.is_empty() {
666                lines.push("- **References:**".to_string());
667                for reference in &record.nvd_references {
668                    lines.push(format!("  - {}", reference));
669                }
670            }
671            lines.push(String::new());
672        }
673    }
674
675    if !recipes.is_empty() {
676        lines.push("## Payload Command Recipes".to_string());
677        lines.push(String::new());
678        for recipe in recipes {
679            lines.push(format!(
680                "### {} / {}",
681                title_case(&recipe.platform),
682                title_case(&recipe.architecture)
683            ));
684            lines.push(String::new());
685            lines.push(format!("- **LHOST:** {}", recipe.lhost));
686            lines.push(format!("- **LPORT:** {}", recipe.lport));
687            lines.push(format!("- **Format:** {}", recipe.format));
688            lines.push(format!("- **Output:** {}", recipe.output_path));
689            lines.push(String::new());
690            lines.push("```bash".to_string());
691            lines.push(recipe.command.clone());
692            lines.push("```".to_string());
693            lines.push(String::new());
694        }
695    }
696
697    lines.push("---".to_string());
698    lines.push(format!(
699        "*Report generated by PloitMalper v{}*",
700        env!("CARGO_PKG_VERSION")
701    ));
702    lines.push(String::new());
703
704    let content = lines.join("\n");
705    fs::write(output_path, content)?;
706    Ok(PathBuf::from(output_path))
707}
708
709// ---------------------------------------------------------------------------
710// Backward-compatible wrappers
711// ---------------------------------------------------------------------------
712
713pub fn render_findings_table(records: &[ScanRecord]) -> String {
714    render_finding_groups(&group_findings(records))
715}
716
717pub fn render_module_table(suggestions: &[ModuleSuggestion]) -> String {
718    render_module_categories(&categorize_modules(suggestions))
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use serde_json::json;
725
726    fn record(title: &str, severity: &str, url: &str) -> ScanRecord {
727        ScanRecord {
728            target: "testasp.vulnweb.com".into(),
729            tool: "vulnmalper".into(),
730            title: title.into(),
731            severity: severity.into(),
732            port: Some(80),
733            cve: None,
734            service: Some("Microsoft-IIS/8.5".into()),
735            raw: json!({ "url": url }),
736            ..Default::default()
737        }
738    }
739
740    #[test]
741    fn console_report_contains_all_sections() {
742        let records = vec![
743            record(
744                "LDAP Injection on QUERY_STRING",
745                "critical",
746                "http://h/search?q=",
747            ),
748            record(
749                "LDAP Injection on QUERY_STRING",
750                "critical",
751                "http://h/login?user=",
752            ),
753            record("Trace.axd exposed", "medium", "http://h/trace.axd"),
754        ];
755        let suggestions = vec![ModuleSuggestion {
756            service_banner: "IIS".into(),
757            suggested_module: "auxiliary/scanner/http/iis_version".into(),
758            confidence: "high".into(),
759        }];
760        let output = render_console_report(&records, &suggestions, &[]);
761        assert!(output.contains("Scan Summary"));
762        assert!(output.contains("Overall Risk"));
763        assert!(output.contains("LDAP Injection"));
764        assert!(output.contains("/search?q="));
765        assert!(output.contains("Microsoft IIS"));
766        assert!(output.contains("Possible Attack Chain"));
767        assert!(output.contains("MITRE ATT&CK Mapping"));
768        assert!(output.contains("CVE Enrichment"));
769    }
770
771    #[test]
772    fn markdown_report_contains_risk_and_endpoints() {
773        let records = vec![record(
774            "LDAP Injection on QUERY_STRING",
775            "critical",
776            "http://h/search?q=",
777        )];
778        let dedup_stats = DedupStats {
779            total: 1,
780            unique: 1,
781            removed: 0,
782        };
783        let path = generate_markdown_report(&records, &[], &[], &dedup_stats, "/tmp/pm_test.md")
784            .expect("write report");
785        let content = std::fs::read_to_string(&path).expect("read report");
786        assert!(content.contains("Overall Risk"));
787        assert!(content.contains("Affected endpoints"));
788        assert!(content.contains("/search?q="));
789        let _ = std::fs::remove_file(path);
790    }
791}