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
87pub 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
386fn 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 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 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 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 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 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 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
709pub 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}