1use std::collections::{BTreeMap, BTreeSet};
2
3use regex::Regex;
4use serde_json::Value;
5
6use crate::models::{
7 FindingGroup, MitreTactic, ModuleCategories, ModuleSuggestion, RiskScore, ScanRecord,
8 ScanSummary,
9};
10
11const SEVERITY_RANK: &[&str] = &["critical", "high", "medium", "low", "info", "unknown"];
12
13pub fn severity_rank(severity: &str) -> usize {
14 let severity = severity.to_lowercase();
15 SEVERITY_RANK
16 .iter()
17 .position(|candidate| *candidate == severity)
18 .unwrap_or(SEVERITY_RANK.len() - 1)
19}
20
21pub fn severity_counts(records: &[ScanRecord]) -> BTreeMap<String, usize> {
22 let mut counts = BTreeMap::new();
23 for record in records {
24 let severity = record.severity.to_lowercase();
25 *counts.entry(severity).or_insert(0) += 1;
26 }
27 counts
28}
29
30fn normalize_endpoint(value: &str) -> Option<String> {
35 let trimmed = value.trim();
36 if trimmed.is_empty() {
37 return None;
38 }
39 let path = match trimmed.split_once("://") {
40 Some((_, rest)) => match rest.find('/') {
41 Some(idx) => &rest[idx..],
42 None => "/",
43 },
44 None => trimmed,
45 };
46 let path = path.trim().trim_end_matches(|c: char| c.is_whitespace());
47 if path.is_empty() {
48 Some("/".to_string())
49 } else {
50 Some(path.to_string())
51 }
52}
53
54fn endpoint_from_raw(raw: &Value) -> Option<String> {
55 for key in [
56 "url",
57 "endpoint",
58 "uri",
59 "location",
60 "request_url",
61 "url_path",
62 "full_url",
63 "request_uri",
64 "vuln_url",
65 "absolute_url",
66 ] {
67 if let Some(value) = raw.get(key).and_then(Value::as_str) {
68 if let Some(endpoint) = normalize_endpoint(value) {
69 return Some(endpoint);
70 }
71 }
72 }
73
74 for key in ["raw", "data", "details", "result", "vuln_data"] {
75 if let Some(nested) = raw.get(key) {
76 if let Some(endpoint) = endpoint_from_raw(nested) {
77 return Some(endpoint);
78 }
79 }
80 }
81
82 for key in ["urls", "endpoints", "paths"] {
83 if let Some(arr) = raw.get(key).and_then(Value::as_array) {
84 for value in arr {
85 if let Some(endpoint) = value.as_str().and_then(normalize_endpoint) {
86 return Some(endpoint);
87 }
88 }
89 }
90 }
91
92 let path = raw
93 .get("path")
94 .and_then(Value::as_str)
95 .or_else(|| raw.get("url_path").and_then(Value::as_str));
96 if let Some(path) = path {
97 let mut endpoint = path.to_string();
98 if let Some(query) = raw
99 .get("query_string")
100 .and_then(Value::as_str)
101 .or_else(|| raw.get("query").and_then(Value::as_str))
102 .or_else(|| raw.get("params").and_then(Value::as_str))
103 {
104 let query = query.trim();
105 if !query.is_empty() {
106 endpoint.push('?');
107 endpoint.push_str(query);
108 }
109 }
110 if let Some(normalized) = normalize_endpoint(&endpoint) {
111 return Some(normalized);
112 }
113 }
114
115 None
116}
117
118fn endpoint_from_title(title: &str) -> Option<String> {
119 let re = Regex::new(r"(?:https?://[^\s/]+)?(/[^\s>]+)").expect("valid regex");
120 for captures in re.captures_iter(title) {
121 if let Some(value) = captures.get(1) {
122 if let Some(endpoint) = normalize_endpoint(value.as_str()) {
123 return Some(endpoint);
124 }
125 }
126 }
127 None
128}
129
130pub fn extract_endpoint(record: &ScanRecord) -> Option<String> {
132 if let Some(endpoint) = endpoint_from_raw(&record.raw) {
133 return Some(endpoint);
134 }
135 if let Some(endpoint) = endpoint_from_title(&record.title) {
136 return Some(endpoint);
137 }
138 None
139}
140
141fn vuln_type_title(title: &str) -> String {
146 let cleaned = title.trim().to_lowercase();
147 let cleaned = cleaned
148 .strip_prefix("vulnerability: ")
149 .or_else(|| cleaned.strip_prefix("vulnerability "))
150 .or_else(|| cleaned.strip_prefix("found: "))
151 .or_else(|| cleaned.strip_prefix("found "))
152 .or_else(|| cleaned.strip_prefix("possible "))
153 .unwrap_or(&cleaned)
154 .trim()
155 .to_string();
156
157 for separator in [" on ", " in ", " at ", " via ", " - ", ": ", " - "] {
158 if let Some((head, _)) = cleaned.split_once(separator) {
159 let head = head.trim();
160 if !head.is_empty() && head.chars().count() > 1 {
161 return head.to_string();
162 }
163 }
164 }
165
166 cleaned
167}
168
169pub fn display_title(title: &str) -> String {
170 format_type_title(&vuln_type_title(title))
171}
172
173const ACRONYMS: &[&str] = &[
174 "ldap", "sql", "xss", "iis", "api", "http", "https", "xxe", "csrf", "ftp", "smtp", "dns",
175 "tls", "ssl", "rce", "lfi", "rfi", "xml", "url", "php", "asp", "os", "json", "csp", "hsts",
176 "xpath", "html", "css", "js", "db", "id", "ui",
177];
178
179fn format_type_title(lowercased: &str) -> String {
180 lowercased
181 .split(|c: char| c.is_whitespace() || c == '_')
182 .filter(|segment| !segment.is_empty())
183 .map(|word| {
184 if word.starts_with("cve-") || ACRONYMS.contains(&word) {
185 word.to_uppercase()
186 } else {
187 let mut chars = word.chars();
188 match chars.next() {
189 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
190 None => String::new(),
191 }
192 }
193 })
194 .collect::<Vec<_>>()
195 .join(" ")
196}
197
198pub fn group_findings(records: &[ScanRecord]) -> Vec<FindingGroup> {
200 let mut groups: BTreeMap<(String, String), FindingGroup> = BTreeMap::new();
201
202 for record in records {
203 let title = display_title(&record.title);
204 let key = (record.target.clone(), title.clone());
205 let entry = groups.entry(key).or_insert_with(|| FindingGroup {
206 title: title.clone(),
207 severity: record.severity.to_lowercase(),
208 count: 0,
209 target: record.target.clone(),
210 affected_endpoints: record.affected_endpoints.clone(),
211 cves: Vec::new(),
212 ports: Vec::new(),
213 tools: Vec::new(),
214 });
215
216 entry.count += 1;
217 if severity_rank(&record.severity) < severity_rank(&entry.severity) {
218 entry.severity = record.severity.to_lowercase();
219 }
220 if let Some(endpoint) = extract_endpoint(record) {
221 if !entry.affected_endpoints.contains(&endpoint) {
222 entry.affected_endpoints.push(endpoint);
223 }
224 }
225 if let Some(cve) = record.cve.as_ref() {
226 if !entry.cves.contains(cve) {
227 entry.cves.push(cve.clone());
228 }
229 }
230 if let Some(port) = record.port {
231 let port_str = port.to_string();
232 if !entry.ports.contains(&port_str) {
233 entry.ports.push(port_str);
234 }
235 }
236 if !entry.tools.contains(&record.tool) {
237 entry.tools.push(record.tool.clone());
238 }
239 }
240
241 let mut groups = groups.into_values().collect::<Vec<_>>();
242 groups.sort_by(|a, b| {
243 severity_rank(&a.severity)
244 .cmp(&severity_rank(&b.severity))
245 .then(b.count.cmp(&a.count))
246 .then(a.title.cmp(&b.title))
247 });
248 groups
249}
250
251fn banner_texts(records: &[ScanRecord]) -> Vec<String> {
256 let mut texts = Vec::new();
257 for record in records {
258 if let Some(service) = record.service.as_ref() {
259 texts.push(service.clone());
260 }
261 texts.push(record.title.clone());
262 for key in [
263 "server",
264 "banner",
265 "software",
266 "product",
267 "version",
268 "http_server",
269 "app",
270 "technology",
271 ] {
272 if let Some(value) = record.raw.get(key).and_then(Value::as_str) {
273 texts.push(value.to_string());
274 }
275 }
276 }
277 texts
278}
279
280fn server_label(name: &str, texts: &[String], keywords: &[&str]) -> Option<String> {
281 let version_re = Regex::new(r"\d+(?:\.\d+){0,3}").expect("valid regex");
282 for text in texts {
283 let lower = text.to_lowercase();
284 if keywords.iter().any(|keyword| lower.contains(keyword)) {
285 if let Some(capture) = version_re.captures(text) {
286 return Some(format!("{} {}", name, &capture[0]));
287 }
288 return Some(name.to_string());
289 }
290 }
291 None
292}
293
294pub fn infer_server(records: &[ScanRecord]) -> Option<String> {
295 let texts = banner_texts(records);
296 if let Some(label) = server_label("Microsoft IIS", &texts, &["microsoft-iis", "iis"]) {
297 return Some(label);
298 }
299 if let Some(label) = server_label("Apache", &texts, &["apache"]) {
300 return Some(label);
301 }
302 if let Some(label) = server_label("Nginx", &texts, &["nginx"]) {
303 return Some(label);
304 }
305 if let Some(label) = server_label("Tomcat", &texts, &["tomcat"]) {
306 return Some(label);
307 }
308 if let Some(label) = server_label("Jetty", &texts, &["jetty"]) {
309 return Some(label);
310 }
311 None
312}
313
314pub fn infer_framework(records: &[ScanRecord]) -> Option<String> {
315 let texts = banner_texts(records);
316 for text in &texts {
317 let lower = text.to_lowercase();
318 if lower.contains("asp.net") || lower.contains("aspx") || lower.contains(".net core") {
319 return Some("ASP.NET".to_string());
320 }
321 if lower.contains("php") {
322 return Some("PHP".to_string());
323 }
324 if lower.contains("spring") || lower.contains("struts") || lower.contains("java ee") {
325 return Some("Java".to_string());
326 }
327 if lower.contains("node.js") || lower.contains("express") {
328 return Some("Node.js".to_string());
329 }
330 if lower.contains("django") || lower.contains("flask") {
331 return Some("Python".to_string());
332 }
333 if lower.contains("rails") || lower.contains("ruby on rails") {
334 return Some("Ruby on Rails".to_string());
335 }
336 if lower.contains("wordpress") {
337 return Some("WordPress".to_string());
338 }
339 if lower.contains("drupal") {
340 return Some("Drupal".to_string());
341 }
342 if lower.contains("iis") {
343 return Some("ASP.NET".to_string());
344 }
345 }
346 None
347}
348
349pub fn build_scan_summary(records: &[ScanRecord]) -> ScanSummary {
350 let host = records
351 .first()
352 .map(|record| record.target.clone())
353 .unwrap_or_else(|| "N/A".to_string());
354
355 let mut top_risks = detect_vuln_kinds(records);
356 let mut sorted = records.to_vec();
357 sorted.sort_by_key(|record| severity_rank(&record.severity));
358 for record in sorted {
359 if record.severity.to_lowercase() == "info" {
360 continue;
361 }
362 let title = display_title(&record.title);
363 if !title.is_empty()
364 && !top_risks
365 .iter()
366 .any(|risk| risk.eq_ignore_ascii_case(&title))
367 && !top_risks
368 .iter()
369 .any(|risk| risk.to_lowercase().contains(&title.to_lowercase()))
370 {
371 top_risks.push(title);
372 }
373 }
374
375 ScanSummary {
376 host,
377 server: infer_server(records),
378 framework: infer_framework(records),
379 severity_counts: severity_counts(records),
380 top_risks,
381 }
382}
383
384const VULN_KEYWORDS: &[(&str, &[&str])] = &[
389 ("SQL Injection", &["sql injection", "sqli", "sql_injection"]),
390 ("LDAP Injection", &["ldap injection", "ldap_injection"]),
391 (
392 "Command Injection",
393 &[
394 "command injection",
395 "remote code execution",
396 " rce",
397 "os injection",
398 ],
399 ),
400 (
401 "XSS",
402 &[
403 "cross-site scripting",
404 " xss",
405 "reflected xss",
406 "stored xss",
407 ],
408 ),
409 (
410 "Path Traversal",
411 &[
412 "path traversal",
413 "directory traversal",
414 "local file inclusion",
415 "remote file inclusion",
416 " lfi",
417 " rfi",
418 ],
419 ),
420 (
421 "File Upload",
422 &["file upload", "arbitrary file upload", "upload bypass"],
423 ),
424 ("XXE", &["xml external entity", " xxe"]),
425 (
426 "Weak Credentials",
427 &[
428 "weak password",
429 "default password",
430 "weak credential",
431 "brute force",
432 "weak authentication",
433 ],
434 ),
435 (
436 "Information Disclosure",
437 &[
438 "information disclosure",
439 "info disclosure",
440 "directory listing",
441 "sensitive data",
442 "verbose error",
443 "trace.axd",
444 "debug page",
445 ],
446 ),
447 (
448 "Missing Security Headers",
449 &[
450 "missing security header",
451 "missing header",
452 "clickjacking",
453 "x-frame-options",
454 "hsts",
455 "content-security-policy",
456 "security header",
457 ],
458 ),
459 ("Open Redirect", &["open redirect", "unvalidated redirect"]),
460 ("CSRF", &["csrf", "cross-site request forgery"]),
461 (
462 "Deserialization",
463 &["deserialization", "insecure deserialization"],
464 ),
465 (
466 "SSL/TLS Weakness",
467 &[
468 "ssl",
469 "tls",
470 "weak cipher",
471 "obsolete protocol",
472 "heartbleed",
473 "poodle",
474 ],
475 ),
476];
477
478pub fn detect_vuln_kinds(records: &[ScanRecord]) -> Vec<String> {
479 let mut kinds = Vec::new();
480 let mut seen = BTreeSet::new();
481 for record in records {
482 let text = format!(
483 "{} {}",
484 record.title.to_lowercase(),
485 raw_text(&record.raw).to_lowercase()
486 );
487 for (name, keywords) in VULN_KEYWORDS {
488 if !seen.contains(*name) && keywords.iter().any(|keyword| text.contains(keyword)) {
489 seen.insert(*name);
490 kinds.push((*name).to_string());
491 }
492 }
493 }
494 kinds
495}
496
497fn raw_text(raw: &Value) -> String {
498 match raw {
499 Value::String(value) => value.clone(),
500 Value::Object(map) => map
501 .values()
502 .filter_map(|value| value.as_str())
503 .collect::<Vec<_>>()
504 .join(" "),
505 Value::Array(items) => items
506 .iter()
507 .filter_map(|item| item.as_str())
508 .collect::<Vec<_>>()
509 .join(" "),
510 _ => String::new(),
511 }
512}
513
514pub fn compute_risk_score(records: &[ScanRecord]) -> RiskScore {
519 let counts = severity_counts(records);
520 let mut score: u32 = 0;
521 score += counts.get("critical").copied().unwrap_or(0) as u32 * 10;
522 score += counts.get("high").copied().unwrap_or(0) as u32 * 7;
523 score += counts.get("medium").copied().unwrap_or(0) as u32 * 4;
524 score += counts.get("low").copied().unwrap_or(0) as u32;
525 score = score.min(100);
526
527 let filled = (score / 10) as usize;
528 let bar = format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled));
529
530 let mut reasons = Vec::new();
531 for kind in detect_vuln_kinds(records).iter().take(3) {
532 reasons.push(kind.clone());
533 }
534 let injection_kinds = reasons
535 .iter()
536 .filter(|reason| {
537 ["SQL Injection", "LDAP Injection", "Command Injection"].contains(&reason.as_str())
538 })
539 .count();
540 if injection_kinds > 1 {
541 reasons.push("Multiple injection points".to_string());
542 }
543 if records.len() >= 5 {
544 reasons.push(format!("{} findings to review", records.len()));
545 }
546 if reasons.is_empty() {
547 reasons.push("No critical or high severity findings".to_string());
548 }
549
550 RiskScore {
551 score,
552 bar,
553 reasons,
554 }
555}
556
557pub fn categorize_modules(suggestions: &[ModuleSuggestion]) -> ModuleCategories {
562 let mut categories = ModuleCategories::default();
563 let mut best: BTreeMap<String, ModuleSuggestion> = BTreeMap::new();
564 for suggestion in suggestions {
565 let key = suggestion.suggested_module.clone();
566 let entry = best.entry(key).or_insert_with(|| suggestion.clone());
567 if confidence_rank(&suggestion.confidence) > confidence_rank(&entry.confidence) {
568 *entry = suggestion.clone();
569 }
570 }
571 for suggestion in best.values() {
572 match classify_module(&suggestion.suggested_module) {
573 ModuleKind::Enumeration => categories.enumeration.push(suggestion.clone()),
574 ModuleKind::Validation => categories.validation.push(suggestion.clone()),
575 ModuleKind::Exploitation => categories.exploitation.push(suggestion.clone()),
576 }
577 }
578 categories
579}
580
581fn confidence_rank(confidence: &str) -> u8 {
582 match confidence.to_lowercase().as_str() {
583 "high" => 3,
584 "medium" => 2,
585 "low" => 1,
586 _ => 0,
587 }
588}
589
590enum ModuleKind {
591 Enumeration,
592 Validation,
593 Exploitation,
594}
595
596fn classify_module(module: &str) -> ModuleKind {
600 if module.starts_with("exploit/") {
601 return ModuleKind::Exploitation;
602 }
603 let last = module.rsplit('/').next().unwrap_or(module);
604 if last.ends_with("_version") || last.ends_with("_scanner") {
605 ModuleKind::Enumeration
606 } else {
607 ModuleKind::Validation
608 }
609}
610
611pub fn manual_exploitation_notes(records: &[ScanRecord]) -> Vec<String> {
615 const MANUAL_ONLY: &[(&str, &[&str])] = &[
616 (
617 "Application-specific SQL Injection",
618 &["sql injection", "sqli"],
619 ),
620 ("Application-specific LDAP Injection", &["ldap injection"]),
621 (
622 "Application-specific XSS",
623 &["cross-site scripting", " xss"],
624 ),
625 (
626 "Application-specific CSRF",
627 &["csrf", "cross-site request forgery"],
628 ),
629 ("Application-specific XXE", &["xml external entity", " xxe"]),
630 (
631 "Application-specific Authentication Flaws",
632 &[
633 "authentication bypass",
634 "session fixation",
635 "weak authentication",
636 ],
637 ),
638 (
639 "Application-specific Business Logic Flaws",
640 &[
641 "business logic",
642 "privilege escalation",
643 "authorization bypass",
644 ],
645 ),
646 ];
647
648 let mut notes = Vec::new();
649 let mut seen = BTreeSet::new();
650 for record in records {
651 let text = format!(
652 "{} {}",
653 record.title.to_lowercase(),
654 raw_text(&record.raw).to_lowercase()
655 );
656 for (name, keywords) in MANUAL_ONLY {
657 if !seen.contains(*name) && keywords.iter().any(|keyword| text.contains(keyword)) {
658 seen.insert(*name);
659 notes.push((*name).to_string());
660 }
661 }
662 }
663 notes
664}
665
666pub fn build_attack_path(records: &[ScanRecord]) -> Vec<String> {
671 let mut steps = Vec::new();
672 steps.push("Internet".to_string());
673
674 if let Some(server) = infer_server(records) {
675 steps.push(server);
676 } else if let Some(host) = records.first().map(|record| record.target.clone()) {
677 steps.push(host);
678 }
679 if let Some(framework) = infer_framework(records) {
680 steps.push(framework);
681 }
682
683 let kinds = detect_vuln_kinds(records);
684 if !kinds.is_empty() {
685 steps.push(kinds[0].clone());
686 } else if let Some(record) = highest_severity_record(records) {
687 steps.push(display_title(&record.title));
688 }
689
690 steps.push(impact_for(&kinds));
691 steps
692}
693
694fn impact_for(kinds: &[String]) -> String {
695 let all = kinds.join(" ");
696 let all = all.to_lowercase();
697 if all.contains("sql injection") || all.contains("ldap injection") {
698 "Database Access".to_string()
699 } else if all.contains("command injection")
700 || all.contains("remote code execution")
701 || all.contains("deserialization")
702 {
703 "Remote Code Execution".to_string()
704 } else if all.contains("file upload") {
705 "Web Shell / RCE".to_string()
706 } else if all.contains("path traversal") {
707 "Sensitive File Disclosure".to_string()
708 } else if all.contains("weak credential") || all.contains("brute force") {
709 "Account Takeover".to_string()
710 } else if all.contains("xss") {
711 "Session Hijacking".to_string()
712 } else {
713 "Privilege Escalation".to_string()
714 }
715}
716
717fn highest_severity_record(records: &[ScanRecord]) -> Option<&ScanRecord> {
718 records
719 .iter()
720 .min_by_key(|record| severity_rank(&record.severity))
721}
722
723const MITRE_RULES: &[(&str, &str, &[&str])] = &[
728 (
729 "Discovery",
730 "T1046 Network Service Discovery",
731 &[
732 "banner",
733 "version disclosure",
734 "service detection",
735 "robots.txt",
736 "directory listing",
737 "trace.axd",
738 "debug page",
739 "information disclosure",
740 ],
741 ),
742 (
743 "Discovery",
744 "T1595 Active Scanning",
745 &["scan", "enumeration", "fingerprint"],
746 ),
747 (
748 "Initial Access",
749 "T1190 Exploit Public-Facing Application",
750 &[
751 "exploit",
752 "cve-",
753 "injection",
754 "xss",
755 "upload",
756 "path traversal",
757 "open redirect",
758 "csrf",
759 "deserialization",
760 "xxe",
761 "rce",
762 "remote code",
763 "vulnerable",
764 "weak authentication",
765 ],
766 ),
767 (
768 "Execution",
769 "T1203 Exploitation for Client Execution",
770 &[
771 "xss",
772 "rce",
773 "command injection",
774 "upload",
775 "deserialization",
776 "webshell",
777 "web shell",
778 ],
779 ),
780 (
781 "Persistence",
782 "T1505 Web Shell",
783 &["upload", "webshell", "web shell", "persistence"],
784 ),
785 (
786 "Credential Access",
787 "T1110 Brute Force",
788 &[
789 "brute force",
790 "weak password",
791 "default password",
792 "weak credential",
793 ],
794 ),
795 (
796 "Credential Access",
797 "T1003 OS Credential Dumping",
798 &[
799 "credential",
800 "sql injection",
801 "ldap injection",
802 "database",
803 "authentication bypass",
804 "session fixation",
805 ],
806 ),
807 (
808 "Credential Access",
809 "T1557 Adversary-in-the-Middle",
810 &["clickjacking", "x-frame-options", "frame injection"],
811 ),
812 (
813 "Collection",
814 "T1071 Application Layer Protocol",
815 &[
816 "sensitive data",
817 "information disclosure",
818 "info disclosure",
819 "data exposure",
820 ],
821 ),
822 (
823 "Defense Evasion",
824 "T1078 Valid Accounts",
825 &["default password", "weak credential", "valid account"],
826 ),
827 (
828 "Lateral Movement",
829 "T1210 Exploitation of Remote Services",
830 &[
831 "smb",
832 "rdp",
833 "eternalblue",
834 "bluekeep",
835 "remote service",
836 "ms17-010",
837 ],
838 ),
839];
840
841pub fn map_mitre(records: &[ScanRecord]) -> Vec<MitreTactic> {
842 if records.is_empty() {
843 return Vec::new();
844 }
845
846 let mut tactics: BTreeMap<String, (BTreeSet<String>, BTreeSet<String>)> = BTreeMap::new();
847
848 tactics
850 .entry("Discovery".to_string())
851 .or_default()
852 .0
853 .insert("T1595 Active Scanning".to_string());
854
855 for record in records {
856 let text = format!(
857 "{} {}",
858 record.title.to_lowercase(),
859 raw_text(&record.raw).to_lowercase()
860 );
861 for (tactic, technique, keywords) in MITRE_RULES {
862 if keywords.iter().any(|keyword| text.contains(keyword)) {
863 let entry = tactics.entry((*tactic).to_string()).or_default();
864 entry.0.insert((*technique).to_string());
865 entry.1.insert(record.title.clone());
866 }
867 }
868 }
869
870 let mut ordered = tactics
871 .into_iter()
872 .map(|(tactic, (techniques, findings))| MitreTactic {
873 tactic,
874 techniques: techniques.into_iter().collect(),
875 findings: findings.into_iter().collect(),
876 })
877 .collect::<Vec<_>>();
878 ordered.sort_by_key(|entry| tactic_rank(&entry.tactic));
879 ordered
880}
881
882const TACTIC_ORDER: &[&str] = &[
883 "Discovery",
884 "Initial Access",
885 "Execution",
886 "Persistence",
887 "Credential Access",
888 "Defense Evasion",
889 "Collection",
890 "Lateral Movement",
891];
892
893fn tactic_rank(tactic: &str) -> usize {
894 TACTIC_ORDER
895 .iter()
896 .position(|known| *known == tactic)
897 .unwrap_or(usize::MAX)
898}
899
900#[cfg(test)]
905mod tests {
906 use super::*;
907 use serde_json::json;
908
909 fn record(title: &str, severity: &str, url: &str) -> ScanRecord {
910 ScanRecord {
911 target: "testasp.vulnweb.com".into(),
912 tool: "vulnmalper".into(),
913 title: title.into(),
914 severity: severity.into(),
915 port: Some(80),
916 cve: None,
917 service: Some("Microsoft-IIS/8.5".into()),
918 raw: json!({ "url": url }),
919 ..Default::default()
920 }
921 }
922
923 #[test]
924 fn groups_findings_and_merges_endpoints() {
925 let records = vec![
926 record(
927 "LDAP Injection on QUERY_STRING",
928 "critical",
929 "http://h/search?q=",
930 ),
931 record(
932 "LDAP Injection on QUERY_STRING",
933 "critical",
934 "http://h/products?id=",
935 ),
936 record(
937 "LDAP Injection on QUERY_STRING",
938 "critical",
939 "http://h/login?user=",
940 ),
941 ];
942 let groups = group_findings(&records);
943 assert_eq!(groups.len(), 1);
944 assert_eq!(groups[0].title, "LDAP Injection");
945 assert_eq!(groups[0].severity, "critical");
946 assert_eq!(groups[0].count, 3);
947 assert_eq!(groups[0].affected_endpoints.len(), 3);
948 assert!(groups[0]
949 .affected_endpoints
950 .contains(&"/search?q=".to_string()));
951 }
952
953 #[test]
954 fn keeps_distinct_vuln_types_separate() {
955 let records = vec![
956 record(
957 "SQL Injection on QUERY_STRING",
958 "critical",
959 "http://h/search?q=",
960 ),
961 record(
962 "LDAP Injection on QUERY_STRING",
963 "critical",
964 "http://h/search?q=",
965 ),
966 ];
967 let groups = group_findings(&records);
968 assert_eq!(groups.len(), 2);
969 }
970
971 #[test]
972 fn infers_server_and_framework() {
973 let records = vec![record("Trace.axd exposed", "medium", "http://h/trace.axd")];
974 assert_eq!(infer_server(&records).as_deref(), Some("Microsoft IIS 8.5"));
975 assert_eq!(infer_framework(&records).as_deref(), Some("ASP.NET"));
976 }
977
978 #[test]
979 fn risk_score_reflects_severity() {
980 let records = vec![
981 record(
982 "SQL Injection on QUERY_STRING",
983 "critical",
984 "http://h/search?q=",
985 ),
986 record(
987 "LDAP Injection on QUERY_STRING",
988 "critical",
989 "http://h/login?user=",
990 ),
991 ];
992 let risk = compute_risk_score(&records);
993 assert_eq!(risk.score, 20);
994 assert_eq!(risk.bar.chars().count(), 10);
995 assert!(risk
996 .reasons
997 .iter()
998 .any(|reason| reason.contains("SQL Injection")));
999 }
1000
1001 #[test]
1002 fn categories_modules() {
1003 let suggestions = vec![
1004 ModuleSuggestion {
1005 service_banner: "IIS".into(),
1006 suggested_module: "auxiliary/scanner/http/iis_version".into(),
1007 confidence: "high".into(),
1008 },
1009 ModuleSuggestion {
1010 service_banner: "robots.txt".into(),
1011 suggested_module: "auxiliary/scanner/http/robots_txt".into(),
1012 confidence: "high".into(),
1013 },
1014 ModuleSuggestion {
1015 service_banner: "tomcat".into(),
1016 suggested_module: "exploit/multi/http/tomcat_jsp_upload_bypass".into(),
1017 confidence: "high".into(),
1018 },
1019 ];
1020 let categories = categorize_modules(&suggestions);
1021 assert_eq!(categories.enumeration.len(), 1);
1022 assert_eq!(categories.validation.len(), 1);
1023 assert_eq!(categories.exploitation.len(), 1);
1024 }
1025
1026 #[test]
1027 fn attack_path_ends_in_database_access_for_injection() {
1028 let records = vec![record(
1029 "SQL Injection on QUERY_STRING",
1030 "critical",
1031 "http://h/search?q=",
1032 )];
1033 let path = build_attack_path(&records);
1034 assert_eq!(path.first().map(String::as_str), Some("Internet"));
1035 assert_eq!(path.last().map(String::as_str), Some("Database Access"));
1036 assert!(path.iter().any(|step| step == "SQL Injection"));
1037 }
1038
1039 #[test]
1040 fn mitre_mapping_flags_injection() {
1041 let records = vec![record(
1042 "SQL Injection on QUERY_STRING",
1043 "critical",
1044 "http://h/search?q=",
1045 )];
1046 let tactics = map_mitre(&records);
1047 assert!(tactics
1048 .iter()
1049 .any(|tactic| tactic.tactic == "Credential Access"));
1050 }
1051
1052 #[test]
1053 fn endpoint_extracted_from_title() {
1054 let rec = ScanRecord {
1055 target: "h".into(),
1056 tool: "t".into(),
1057 title: "SQL Injection at /products?id=1".into(),
1058 severity: "high".into(),
1059 port: None,
1060 cve: None,
1061 service: None,
1062 raw: json!({}),
1063 ..Default::default()
1064 };
1065 assert_eq!(extract_endpoint(&rec).as_deref(), Some("/products?id=1"));
1066 }
1067}