1use std::collections::{BTreeMap, BTreeSet};
2
3use regex::Regex;
4use serde_json::Value;
5
6use crate::models::{
7 ExploitabilityInfo, FindingGroup, InjectableEndpoint, MitreTactic, ModuleCategories,
8 ModuleSuggestion, RiskScore, ScanRecord, 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"(?i)(?:https?://[^\s/]+(/[^\s>]+)|(/[^\s>]+))").expect("valid regex");
120 for captures in re.captures_iter(title) {
121 let value = captures
122 .get(1)
123 .or_else(|| captures.get(2))
124 .map(|m| m.as_str());
125 if let Some(value) = value {
126 if let Some(endpoint) = normalize_endpoint(value) {
127 return Some(endpoint);
128 }
129 }
130 }
131 None
132}
133
134pub fn extract_endpoint(record: &ScanRecord) -> Option<String> {
136 if let Some(endpoint) = endpoint_from_raw(&record.raw) {
137 return Some(endpoint);
138 }
139 if let Some(endpoint) = endpoint_from_title(&record.title) {
140 return Some(endpoint);
141 }
142 None
143}
144
145const SECURITY_HEADERS: &[&str] = &[
150 "content-security-policy",
151 "strict-transport-security",
152 "x-content-type-options",
153 "referrer-policy",
154 "permissions-policy",
155 "x-frame-options",
156 "x-xss-protection",
157 "cross-origin-opener-policy",
158 "cross-origin-resource-policy",
159];
160
161const RECON_INFO_KEYWORDS: &[&str] = &[
162 "alive",
163 "no waf detected",
164 "discovered file",
165 "found via",
166 "http server",
167 "http title",
168 "detected",
169 "status code",
170 "fingerprint",
171 "technolog",
172];
173
174pub fn classify_family(record: &ScanRecord) -> (String, String) {
180 let text = format!(
181 "{} {}",
182 record.title.to_lowercase(),
183 raw_text(&record.raw).to_lowercase()
184 );
185
186 if let Some(header) = missing_header(&text) {
187 return (
188 "Missing Security Headers".to_string(),
189 format!("Missing {}", display_header(&header)),
190 );
191 }
192 if text.contains("clickjacking") || text.contains("x-frame-options") {
193 return (
194 "Missing Security Headers".to_string(),
195 "Clickjacking protection".to_string(),
196 );
197 }
198
199 if [
200 "ssl",
201 "tls",
202 "weak cipher",
203 "obsolete protocol",
204 "heartbleed",
205 "poodle",
206 "protocol",
207 ]
208 .iter()
209 .any(|keyword| text.contains(keyword))
210 {
211 return ("SSL/TLS Weakness".to_string(), display_title(&record.title));
212 }
213
214 if [
215 "sql injection",
216 "ldap injection",
217 "command injection",
218 "cross-site scripting",
219 " xss",
220 "path traversal",
221 "directory traversal",
222 "file upload",
223 "xml external entity",
224 " xxe",
225 "csrf",
226 "deserialization",
227 "open redirect",
228 "remote code execution",
229 " rce",
230 "local file inclusion",
231 "remote file inclusion",
232 ]
233 .iter()
234 .any(|keyword| text.contains(keyword))
235 {
236 return (display_title(&record.title), display_title(&record.title));
237 }
238
239 if [
240 "information disclosure",
241 "info disclosure",
242 "leaks",
243 "directory listing",
244 "trace.axd",
245 "debug page",
246 "inodes",
247 "verbose error",
248 "sensitive data",
249 ]
250 .iter()
251 .any(|keyword| text.contains(keyword))
252 {
253 return (
254 "Information Disclosure".to_string(),
255 display_title(&record.title),
256 );
257 }
258
259 if RECON_INFO_KEYWORDS
260 .iter()
261 .any(|keyword| text.contains(keyword))
262 {
263 return (
264 "Reconnaissance / Informational".to_string(),
265 display_title(&record.title),
266 );
267 }
268
269 (display_title(&record.title), display_title(&record.title))
270}
271
272fn missing_header(text: &str) -> Option<String> {
273 for header in SECURITY_HEADERS {
274 if text.contains(header) {
275 return Some((*header).to_string());
276 }
277 }
278 let re = Regex::new(r"missing[: ]+([a-z0-9-]+)").expect("valid header regex");
279 if let Some(captures) = re.captures(text) {
280 if let Some(header) = captures.get(1) {
281 let header = header.as_str().to_lowercase();
282 if SECURITY_HEADERS
283 .iter()
284 .any(|known| known.contains(&header) || header.contains(known))
285 {
286 return Some(header);
287 }
288 }
289 }
290 None
291}
292
293fn display_header(header: &str) -> String {
297 header
298 .split('-')
299 .filter(|segment| !segment.is_empty())
300 .map(|segment| {
301 let lower = segment.to_lowercase();
302 if lower == "x" {
303 "X".to_string()
304 } else {
305 let mut chars = lower.chars();
306 match chars.next() {
307 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
308 None => String::new(),
309 }
310 }
311 })
312 .collect::<Vec<_>>()
313 .join("-")
314}
315
316fn vuln_type_title(title: &str) -> String {
317 let cleaned = title.trim().to_lowercase();
318 let cleaned = cleaned
319 .strip_prefix("vulnerability: ")
320 .or_else(|| cleaned.strip_prefix("vulnerability "))
321 .or_else(|| cleaned.strip_prefix("found: "))
322 .or_else(|| cleaned.strip_prefix("found "))
323 .or_else(|| cleaned.strip_prefix("possible "))
324 .unwrap_or(&cleaned)
325 .trim()
326 .to_string();
327
328 for separator in [" on ", " in ", " at ", " via ", " - ", ": ", " - "] {
329 if let Some((head, _)) = cleaned.split_once(separator) {
330 let head = head.trim();
331 if !head.is_empty() && head.chars().count() > 1 {
332 return head.to_string();
333 }
334 }
335 }
336
337 cleaned
338}
339
340pub fn display_title(title: &str) -> String {
341 format_type_title(&vuln_type_title(title))
342}
343
344const ACRONYMS: &[&str] = &[
345 "ldap", "sql", "xss", "iis", "api", "http", "https", "xxe", "csrf", "ftp", "smtp", "dns",
346 "tls", "ssl", "rce", "lfi", "rfi", "xml", "url", "php", "asp", "os", "json", "csp", "hsts",
347 "xpath", "html", "css", "js", "db", "id", "ui",
348];
349
350fn format_type_title(lowercased: &str) -> String {
351 lowercased
352 .split(|c: char| c.is_whitespace() || c == '_')
353 .filter(|segment| !segment.is_empty())
354 .map(|word| {
355 if word.starts_with("cve-") || ACRONYMS.contains(&word) {
356 word.to_uppercase()
357 } else {
358 let mut chars = word.chars();
359 match chars.next() {
360 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
361 None => String::new(),
362 }
363 }
364 })
365 .collect::<Vec<_>>()
366 .join(" ")
367}
368
369pub fn group_findings(records: &[ScanRecord]) -> Vec<FindingGroup> {
371 let mut groups: BTreeMap<(String, String), FindingGroup> = BTreeMap::new();
372
373 for record in records {
374 let (family, specific) = classify_family(record);
375 let key = (record.target.clone(), family.clone());
376 let entry = groups.entry(key).or_insert_with(|| FindingGroup {
377 title: family.clone(),
378 severity: record.severity.to_lowercase(),
379 count: 0,
380 target: record.target.clone(),
381 affected_endpoints: record.affected_endpoints.clone(),
382 cves: Vec::new(),
383 ports: Vec::new(),
384 tools: Vec::new(),
385 family: family.clone(),
386 details: Vec::new(),
387 evidence: Vec::new(),
388 });
389
390 entry.count += record.merged_count.max(1);
391 if severity_rank(&record.severity) < severity_rank(&entry.severity) {
392 entry.severity = record.severity.to_lowercase();
393 }
394 if let Some(endpoint) = extract_endpoint(record) {
395 if !entry.affected_endpoints.contains(&endpoint) {
396 entry.affected_endpoints.push(endpoint);
397 }
398 }
399 for cve in &record.cves {
400 if !entry.cves.contains(cve) {
401 entry.cves.push(cve.clone());
402 }
403 }
404 if let Some(port) = record.port {
405 let port_str = port.to_string();
406 if !entry.ports.contains(&port_str) {
407 entry.ports.push(port_str);
408 }
409 }
410 if !entry.tools.contains(&record.tool) {
411 entry.tools.push(record.tool.clone());
412 }
413 if !entry.details.contains(&specific) {
414 entry.details.push(specific);
415 }
416 for evidence in record
417 .raw_findings
418 .iter()
419 .chain(std::iter::once(&record.title))
420 {
421 if !entry.evidence.contains(evidence) {
422 entry.evidence.push(evidence.clone());
423 }
424 }
425 }
426
427 let mut groups = groups.into_values().collect::<Vec<_>>();
428 groups.sort_by(|a, b| {
429 severity_rank(&a.severity)
430 .cmp(&severity_rank(&b.severity))
431 .then(b.count.cmp(&a.count))
432 .then(a.title.cmp(&b.title))
433 });
434 groups
435}
436
437fn banner_texts(records: &[ScanRecord]) -> Vec<String> {
442 let mut texts = Vec::new();
443 for record in records {
444 if let Some(service) = record.service.as_ref() {
445 texts.push(service.clone());
446 }
447 texts.push(record.title.clone());
448 for key in [
449 "server",
450 "banner",
451 "software",
452 "product",
453 "version",
454 "http_server",
455 "app",
456 "technology",
457 ] {
458 if let Some(value) = record.raw.get(key).and_then(Value::as_str) {
459 texts.push(value.to_string());
460 }
461 }
462 }
463 texts
464}
465
466fn server_label(name: &str, texts: &[String], keywords: &[&str]) -> Option<String> {
467 let pattern = format!(
468 r"(?i)(?:{})\s*/?\s*(\d+(?:\.\d+){{0,3}})",
469 keywords.join("|")
470 );
471 let version_re = Regex::new(&pattern).ok();
472 for text in texts {
473 let lower = text.to_lowercase();
474 if keywords.iter().any(|keyword| lower.contains(keyword)) {
475 if let Some(re) = &version_re {
476 if let Some(capture) = re.captures(text) {
477 return Some(format!("{} {}", name, &capture[1]));
478 }
479 }
480 return Some(name.to_string());
481 }
482 }
483 None
484}
485
486pub fn infer_server(records: &[ScanRecord]) -> Option<String> {
487 let texts = banner_texts(records);
488 if let Some(label) = server_label("Microsoft IIS", &texts, &["microsoft-iis", "iis"]) {
489 return Some(label);
490 }
491 if let Some(label) = server_label("Apache", &texts, &["apache"]) {
492 return Some(label);
493 }
494 if let Some(label) = server_label("Nginx", &texts, &["nginx"]) {
495 return Some(label);
496 }
497 if let Some(label) = server_label("Tomcat", &texts, &["tomcat"]) {
498 return Some(label);
499 }
500 if let Some(label) = server_label("Jetty", &texts, &["jetty"]) {
501 return Some(label);
502 }
503 None
504}
505
506pub fn infer_framework(records: &[ScanRecord]) -> Option<String> {
507 let texts = banner_texts(records);
508 for text in &texts {
509 let lower = text.to_lowercase();
510 if lower.contains("asp.net") || lower.contains("aspx") || lower.contains(".net core") {
511 return Some("ASP.NET".to_string());
512 }
513 if lower.contains("php") {
514 return Some("PHP".to_string());
515 }
516 if lower.contains("spring") || lower.contains("struts") || lower.contains("java ee") {
517 return Some("Java".to_string());
518 }
519 if lower.contains("node.js") || lower.contains("express") {
520 return Some("Node.js".to_string());
521 }
522 if lower.contains("django") || lower.contains("flask") {
523 return Some("Python".to_string());
524 }
525 if lower.contains("rails") || lower.contains("ruby on rails") {
526 return Some("Ruby on Rails".to_string());
527 }
528 if lower.contains("wordpress") {
529 return Some("WordPress".to_string());
530 }
531 if lower.contains("drupal") {
532 return Some("Drupal".to_string());
533 }
534 if lower.contains("iis") {
535 return Some("ASP.NET".to_string());
536 }
537 }
538 None
539}
540
541pub fn build_scan_summary(records: &[ScanRecord]) -> ScanSummary {
542 let host = records
543 .first()
544 .map(|record| record.target.clone())
545 .unwrap_or_else(|| "N/A".to_string());
546
547 let mut top_risks = detect_vuln_kinds(records);
548 let mut sorted = records.to_vec();
549 sorted.sort_by_key(|record| severity_rank(&record.severity));
550 for record in sorted {
551 if record.severity.to_lowercase() == "info" {
552 continue;
553 }
554 let title = display_title(&record.title);
555 if !title.is_empty()
556 && !top_risks
557 .iter()
558 .any(|risk| risk.eq_ignore_ascii_case(&title))
559 && !top_risks
560 .iter()
561 .any(|risk| risk.to_lowercase().contains(&title.to_lowercase()))
562 {
563 top_risks.push(title);
564 }
565 }
566
567 ScanSummary {
568 host,
569 server: infer_server(records),
570 framework: infer_framework(records),
571 severity_counts: severity_counts(records),
572 top_risks,
573 }
574}
575
576const VULN_KEYWORDS: &[(&str, &[&str])] = &[
581 ("SQL Injection", &["sql injection", "sqli", "sql_injection"]),
582 ("LDAP Injection", &["ldap injection", "ldap_injection"]),
583 (
584 "Command Injection",
585 &[
586 "command injection",
587 "remote code execution",
588 " rce",
589 "os injection",
590 ],
591 ),
592 (
593 "XSS",
594 &[
595 "cross-site scripting",
596 " xss",
597 "reflected xss",
598 "stored xss",
599 ],
600 ),
601 (
602 "Path Traversal",
603 &[
604 "path traversal",
605 "directory traversal",
606 "local file inclusion",
607 "remote file inclusion",
608 " lfi",
609 " rfi",
610 ],
611 ),
612 (
613 "File Upload",
614 &["file upload", "arbitrary file upload", "upload bypass"],
615 ),
616 ("XXE", &["xml external entity", " xxe"]),
617 (
618 "Weak Credentials",
619 &[
620 "weak password",
621 "default password",
622 "weak credential",
623 "brute force",
624 "weak authentication",
625 ],
626 ),
627 (
628 "Information Disclosure",
629 &[
630 "information disclosure",
631 "info disclosure",
632 "directory listing",
633 "sensitive data",
634 "verbose error",
635 "trace.axd",
636 "debug page",
637 ],
638 ),
639 (
640 "Missing Security Headers",
641 &[
642 "missing security header",
643 "missing header",
644 "clickjacking",
645 "x-frame-options",
646 "hsts",
647 "content-security-policy",
648 "security header",
649 ],
650 ),
651 ("Open Redirect", &["open redirect", "unvalidated redirect"]),
652 ("CSRF", &["csrf", "cross-site request forgery"]),
653 (
654 "Deserialization",
655 &["deserialization", "insecure deserialization"],
656 ),
657 (
658 "SSL/TLS Weakness",
659 &[
660 "ssl",
661 "tls",
662 "weak cipher",
663 "obsolete protocol",
664 "heartbleed",
665 "poodle",
666 ],
667 ),
668];
669
670pub fn detect_vuln_kinds(records: &[ScanRecord]) -> Vec<String> {
671 let mut kinds = Vec::new();
672 let mut seen = BTreeSet::new();
673 for record in records {
674 let text = format!(
675 "{} {}",
676 record.title.to_lowercase(),
677 raw_text(&record.raw).to_lowercase()
678 );
679 for (name, keywords) in VULN_KEYWORDS {
680 if !seen.contains(*name) && keywords.iter().any(|keyword| text.contains(keyword)) {
681 seen.insert(*name);
682 kinds.push((*name).to_string());
683 }
684 }
685 }
686 kinds
687}
688
689fn raw_text(raw: &Value) -> String {
690 match raw {
691 Value::String(value) => value.clone(),
692 Value::Object(map) => map
693 .values()
694 .filter_map(|value| value.as_str())
695 .collect::<Vec<_>>()
696 .join(" "),
697 Value::Array(items) => items
698 .iter()
699 .filter_map(|item| item.as_str())
700 .collect::<Vec<_>>()
701 .join(" "),
702 _ => String::new(),
703 }
704}
705
706pub fn compute_risk_score(records: &[ScanRecord], injectable: &[InjectableEndpoint]) -> RiskScore {
715 let mut score: f64 = 0.0;
716
717 let mut info_total = 0.0;
719 for record in records {
720 let weight = match record.severity.to_lowercase().as_str() {
721 "critical" => 10.0,
722 "high" => 7.0,
723 "medium" => 4.0,
724 "low" => 1.0,
725 "info" => 0.1,
726 _ => 0.0,
727 };
728 if record.severity.to_lowercase() == "info" {
729 info_total += weight;
730 } else {
731 score += weight;
732 }
733 }
734 score += info_total.min(2.0);
735
736 if let Some(cvss) = records
738 .iter()
739 .filter_map(|record| record.nvd_cvss_v3.map(|c| c as f64))
740 .reduce(f64::max)
741 {
742 score += cvss * 2.0;
743 }
744 if records.iter().all(|record| record.nvd_cvss_v3.is_none()) {
745 if let Some(cvss) = records
746 .iter()
747 .filter_map(|record| record.nvd_cvss_v2.map(|c| c as f64))
748 .reduce(f64::max)
749 {
750 score += cvss;
751 }
752 }
753
754 let mut exploitable_cves = Vec::new();
756 let mut known_cves = BTreeSet::new();
757 for cve in records
758 .iter()
759 .flat_map(|record| record.cves.iter())
760 .collect::<Vec<_>>()
761 {
762 known_cves.insert(cve.clone());
763 if let Some(extra) = crate::enrich::enrich_cve(cve) {
764 if extra.public_exploit || extra.in_kev || !extra.metasploit_modules.is_empty() {
765 exploitable_cves.push(cve.clone());
766 }
767 }
768 }
769 score += (exploitable_cves.len() as f64 * 20.0).min(40.0);
770 score += (known_cves.len() as f64 * 5.0).min(20.0);
771
772 let injectable_count = injectable.len();
774 score += (injectable_count as f64 * 8.0).min(40.0);
775
776 let mut services = BTreeSet::new();
778 for record in records {
779 if let Some(service) = record.service.as_deref() {
780 services.insert(service.to_lowercase());
781 }
782 if let Some(port) = record.port {
783 services.insert(port.to_string());
784 }
785 }
786 score += (services.len() as f64).min(5.0);
787
788 let score = score.round().min(100.0) as u32;
789
790 let filled = (score / 10) as usize;
791 let bar = format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled));
792
793 let mut reasons = Vec::new();
794 for cve in &exploitable_cves {
795 reasons.push(format!(
796 "Known exploitable CVE: {} (public exploit/KEV)",
797 cve
798 ));
799 }
800 if injectable_count > 0 {
801 let sample = injectable
802 .iter()
803 .take(2)
804 .map(|entry| entry.endpoint.clone())
805 .collect::<Vec<_>>()
806 .join(", ");
807 reasons.push(format!(
808 "{} injectable endpoint(s): {}",
809 injectable_count, sample
810 ));
811 }
812 if let Some(cvss) = records
813 .iter()
814 .filter_map(|record| record.nvd_cvss_v3.map(|c| c as f64))
815 .reduce(f64::max)
816 {
817 reasons.push(format!("Highest CVSS v3: {:.1}", cvss));
818 }
819 if let Some(severity) = records
820 .iter()
821 .map(|record| record.severity.to_lowercase())
822 .find(|s| s == "critical" || s == "high")
823 {
824 reasons.push(format!("Presence of {} severity findings", severity));
825 }
826 let info_count = records
827 .iter()
828 .filter(|record| record.severity.to_lowercase() == "info")
829 .count();
830 if info_count > 0 {
831 reasons.push(format!(
832 "{} informational finding(s) (heavily discounted)",
833 info_count
834 ));
835 }
836 if !known_cves.is_empty() {
837 reasons.push(format!(
838 "{} known CVE(s) discovered: {}",
839 known_cves.len(),
840 known_cves
841 .iter()
842 .take(3)
843 .cloned()
844 .collect::<Vec<_>>()
845 .join(", ")
846 ));
847 }
848 if reasons.is_empty() {
849 reasons.push("No critical or high severity findings".to_string());
850 }
851
852 RiskScore {
853 score,
854 bar,
855 reasons,
856 }
857}
858
859pub fn exploitability_assessment(
866 records: &[ScanRecord],
867 injectable: &[InjectableEndpoint],
868) -> ExploitabilityInfo {
869 let mut exploitable = BTreeSet::new();
870 for cve in records.iter().flat_map(|record| record.cves.iter()) {
871 if let Some(extra) = crate::enrich::enrich_cve(cve) {
872 if extra.public_exploit || extra.in_kev || !extra.metasploit_modules.is_empty() {
873 exploitable.insert(cve.clone());
874 }
875 }
876 }
877
878 let injectable_count = injectable.len();
879 let mut score: f64 = 0.0;
880 let mut reasons = Vec::new();
881
882 for cve in &exploitable {
883 score += 30.0;
884 let extra = crate::enrich::enrich_cve(cve);
885 let mut line = format!("{} — public exploit", cve);
886 if let Some(extra) = extra {
887 if extra.in_kev {
888 line.push_str(" + listed in CISA KEV");
889 }
890 if !extra.metasploit_modules.is_empty() {
891 line.push_str(&format!(
892 " + {} Metasploit module(s)",
893 extra.metasploit_modules.len()
894 ));
895 }
896 }
897 reasons.push(line);
898 }
899
900 if injectable_count > 0 {
901 score += (injectable_count as f64 * 15.0).min(45.0);
902 reasons.push(format!(
903 "{} injectable endpoint(s) flagged by scanner",
904 injectable_count
905 ));
906 }
907
908 if let Some(cvss) = records
909 .iter()
910 .filter_map(|record| record.nvd_cvss_v3.map(|c| c as f64))
911 .reduce(f64::max)
912 {
913 score += (cvss * 2.0).min(30.0);
914 reasons.push(format!("Highest CVSS v3: {:.1}", cvss));
915 }
916
917 if exploitable.is_empty() && injectable_count == 0 {
918 reasons.push(
919 "No public exploits, KEV entries, or injectable endpoints in this scan".to_string(),
920 );
921 }
922
923 let score = score.round().min(100.0) as u32;
924 let rating = match score {
925 70..=100 => "Very High",
926 50..=69 => "High",
927 30..=49 => "Moderate",
928 15..=29 => "Low",
929 _ => "Informational",
930 };
931
932 ExploitabilityInfo {
933 rating: rating.to_string(),
934 score,
935 exploitable_cves: exploitable.into_iter().collect(),
936 injectable_count,
937 reasons,
938 }
939}
940
941pub fn categorize_modules(suggestions: &[ModuleSuggestion]) -> ModuleCategories {
946 let mut categories = ModuleCategories::default();
947 let mut best: BTreeMap<String, ModuleSuggestion> = BTreeMap::new();
948 for suggestion in suggestions {
949 let key = suggestion.suggested_module.clone();
950 let entry = best.entry(key).or_insert_with(|| suggestion.clone());
951 if confidence_rank(&suggestion.confidence) > confidence_rank(&entry.confidence) {
952 *entry = suggestion.clone();
953 }
954 }
955 for suggestion in best.values() {
956 match classify_module(&suggestion.suggested_module) {
957 ModuleKind::Enumeration => categories.enumeration.push(suggestion.clone()),
958 ModuleKind::Validation => categories.validation.push(suggestion.clone()),
959 ModuleKind::Exploitation => categories.exploitation.push(suggestion.clone()),
960 }
961 }
962 categories
963}
964
965fn confidence_rank(confidence: &str) -> u8 {
966 match confidence.to_lowercase().as_str() {
967 "high" => 3,
968 "medium" => 2,
969 "low" => 1,
970 _ => 0,
971 }
972}
973
974enum ModuleKind {
975 Enumeration,
976 Validation,
977 Exploitation,
978}
979
980fn classify_module(module: &str) -> ModuleKind {
984 if module.starts_with("exploit/") {
985 return ModuleKind::Exploitation;
986 }
987 let last = module.rsplit('/').next().unwrap_or(module);
988 if last.ends_with("_version") || last.ends_with("_scanner") {
989 ModuleKind::Enumeration
990 } else {
991 ModuleKind::Validation
992 }
993}
994
995pub fn manual_exploitation_notes(records: &[ScanRecord]) -> Vec<String> {
999 const MANUAL_ONLY: &[(&str, &[&str])] = &[
1000 (
1001 "Application-specific SQL Injection",
1002 &["sql injection", "sqli"],
1003 ),
1004 ("Application-specific LDAP Injection", &["ldap injection"]),
1005 (
1006 "Application-specific XSS",
1007 &["cross-site scripting", " xss"],
1008 ),
1009 (
1010 "Application-specific CSRF",
1011 &["csrf", "cross-site request forgery"],
1012 ),
1013 ("Application-specific XXE", &["xml external entity", " xxe"]),
1014 (
1015 "Application-specific Authentication Flaws",
1016 &[
1017 "authentication bypass",
1018 "session fixation",
1019 "weak authentication",
1020 ],
1021 ),
1022 (
1023 "Application-specific Business Logic Flaws",
1024 &[
1025 "business logic",
1026 "privilege escalation",
1027 "authorization bypass",
1028 ],
1029 ),
1030 ];
1031
1032 let mut notes = Vec::new();
1033 let mut seen = BTreeSet::new();
1034 for record in records {
1035 let text = format!(
1036 "{} {}",
1037 record.title.to_lowercase(),
1038 raw_text(&record.raw).to_lowercase()
1039 );
1040 for (name, keywords) in MANUAL_ONLY {
1041 if !seen.contains(*name) && keywords.iter().any(|keyword| text.contains(keyword)) {
1042 seen.insert(*name);
1043 notes.push((*name).to_string());
1044 }
1045 }
1046 }
1047 notes
1048}
1049
1050pub fn build_attack_chain(
1062 records: &[ScanRecord],
1063 injectable: &[InjectableEndpoint],
1064) -> Option<Vec<String>> {
1065 let known_exploitable = records.iter().find_map(|record| {
1066 record.cves.iter().find_map(|cve| {
1067 crate::enrich::enrich_cve(cve)
1068 .map(|extra| {
1069 extra.public_exploit || extra.in_kev || !extra.metasploit_modules.is_empty()
1070 })
1071 .unwrap_or(false)
1072 .then_some((cve.clone(), record.target.clone()))
1073 })
1074 });
1075
1076 if let Some((cve, target)) = known_exploitable {
1077 let mut steps = Vec::new();
1078 steps.push("Internet".to_string());
1079 steps.push(format!("Public Service ({})", target));
1080 steps.push(cve.clone());
1081 steps.push("Remote Code Execution".to_string());
1082 return Some(steps);
1083 }
1084
1085 if let Some(first) = injectable.first() {
1086 let mut steps = Vec::new();
1087 steps.push("Internet".to_string());
1088 steps.push(format!("HTTP Service ({})", first.target));
1089 steps.push("Potential Injection Point".to_string());
1090 steps.push("Manual Validation Required".to_string());
1091 return Some(steps);
1092 }
1093
1094 None
1095}
1096
1097pub fn build_attack_path(records: &[ScanRecord]) -> Vec<String> {
1100 build_attack_chain(records, &[]).unwrap_or_default()
1101}
1102
1103const MITRE_RULES: &[(&str, &str, &[&str])] = &[
1108 (
1109 "Discovery",
1110 "T1046 Network Service Discovery",
1111 &[
1112 "banner",
1113 "version disclosure",
1114 "service detection",
1115 "robots.txt",
1116 "directory listing",
1117 "trace.axd",
1118 "debug page",
1119 "information disclosure",
1120 ],
1121 ),
1122 (
1123 "Discovery",
1124 "T1595 Active Scanning",
1125 &["scan", "enumeration", "fingerprint"],
1126 ),
1127 (
1128 "Initial Access",
1129 "T1190 Exploit Public-Facing Application",
1130 &[
1131 "exploit",
1132 "cve-",
1133 "injection",
1134 "xss",
1135 "upload",
1136 "path traversal",
1137 "open redirect",
1138 "csrf",
1139 "deserialization",
1140 "xxe",
1141 "rce",
1142 "remote code",
1143 "vulnerable",
1144 "weak authentication",
1145 ],
1146 ),
1147 (
1148 "Execution",
1149 "T1203 Exploitation for Client Execution",
1150 &[
1151 "xss",
1152 "rce",
1153 "command injection",
1154 "upload",
1155 "deserialization",
1156 "webshell",
1157 "web shell",
1158 ],
1159 ),
1160 (
1161 "Persistence",
1162 "T1505 Web Shell",
1163 &["upload", "webshell", "web shell", "persistence"],
1164 ),
1165 (
1166 "Credential Access",
1167 "T1110 Brute Force",
1168 &[
1169 "brute force",
1170 "weak password",
1171 "default password",
1172 "weak credential",
1173 ],
1174 ),
1175 (
1176 "Credential Access",
1177 "T1003 OS Credential Dumping",
1178 &[
1179 "credential",
1180 "sql injection",
1181 "ldap injection",
1182 "database",
1183 "authentication bypass",
1184 "session fixation",
1185 ],
1186 ),
1187 (
1188 "Credential Access",
1189 "T1557 Adversary-in-the-Middle",
1190 &["clickjacking", "x-frame-options", "frame injection"],
1191 ),
1192 (
1193 "Collection",
1194 "T1071 Application Layer Protocol",
1195 &[
1196 "sensitive data",
1197 "information disclosure",
1198 "info disclosure",
1199 "data exposure",
1200 ],
1201 ),
1202 (
1203 "Defense Evasion",
1204 "T1078 Valid Accounts",
1205 &["default password", "weak credential", "valid account"],
1206 ),
1207 (
1208 "Lateral Movement",
1209 "T1210 Exploitation of Remote Services",
1210 &[
1211 "smb",
1212 "rdp",
1213 "eternalblue",
1214 "bluekeep",
1215 "remote service",
1216 "ms17-010",
1217 ],
1218 ),
1219];
1220
1221pub fn map_mitre(records: &[ScanRecord]) -> Vec<MitreTactic> {
1222 if records.is_empty() {
1223 return Vec::new();
1224 }
1225
1226 let mut tactics: BTreeMap<String, (BTreeSet<String>, BTreeSet<String>)> = BTreeMap::new();
1227
1228 tactics
1230 .entry("Discovery".to_string())
1231 .or_default()
1232 .0
1233 .insert("T1595 Active Scanning".to_string());
1234
1235 for record in records {
1236 let text = format!(
1237 "{} {}",
1238 record.title.to_lowercase(),
1239 raw_text(&record.raw).to_lowercase()
1240 );
1241 for (tactic, technique, keywords) in MITRE_RULES {
1242 if keywords.iter().any(|keyword| text.contains(keyword)) {
1243 let entry = tactics.entry((*tactic).to_string()).or_default();
1244 entry.0.insert((*technique).to_string());
1245 entry.1.insert(record.title.clone());
1246 }
1247 }
1248 }
1249
1250 let mut ordered = tactics
1251 .into_iter()
1252 .map(|(tactic, (techniques, findings))| MitreTactic {
1253 tactic,
1254 techniques: techniques.into_iter().collect(),
1255 findings: findings.into_iter().collect(),
1256 })
1257 .collect::<Vec<_>>();
1258 ordered.sort_by_key(|entry| tactic_rank(&entry.tactic));
1259 ordered
1260}
1261
1262const TACTIC_ORDER: &[&str] = &[
1263 "Discovery",
1264 "Initial Access",
1265 "Execution",
1266 "Persistence",
1267 "Credential Access",
1268 "Defense Evasion",
1269 "Collection",
1270 "Lateral Movement",
1271];
1272
1273fn tactic_rank(tactic: &str) -> usize {
1274 TACTIC_ORDER
1275 .iter()
1276 .position(|known| *known == tactic)
1277 .unwrap_or(usize::MAX)
1278}
1279
1280#[cfg(test)]
1285mod tests {
1286 use super::*;
1287 use serde_json::json;
1288
1289 fn record(title: &str, severity: &str, url: &str) -> ScanRecord {
1290 ScanRecord {
1291 target: "testasp.vulnweb.com".into(),
1292 tool: "vulnmalper".into(),
1293 title: title.into(),
1294 severity: severity.into(),
1295 port: Some(80),
1296 cve: None,
1297 service: Some("Microsoft-IIS/8.5".into()),
1298 raw: json!({ "url": url }),
1299 ..Default::default()
1300 }
1301 }
1302
1303 #[test]
1304 fn groups_findings_and_merges_endpoints() {
1305 let records = vec![
1306 record(
1307 "LDAP Injection on QUERY_STRING",
1308 "critical",
1309 "http://h/search?q=",
1310 ),
1311 record(
1312 "LDAP Injection on QUERY_STRING",
1313 "critical",
1314 "http://h/products?id=",
1315 ),
1316 record(
1317 "LDAP Injection on QUERY_STRING",
1318 "critical",
1319 "http://h/login?user=",
1320 ),
1321 ];
1322 let groups = group_findings(&records);
1323 assert_eq!(groups.len(), 1);
1324 assert_eq!(groups[0].title, "LDAP Injection");
1325 assert_eq!(groups[0].severity, "critical");
1326 assert_eq!(groups[0].count, 3);
1327 assert_eq!(groups[0].affected_endpoints.len(), 3);
1328 assert!(groups[0]
1329 .affected_endpoints
1330 .contains(&"/search?q=".to_string()));
1331 }
1332
1333 #[test]
1334 fn keeps_distinct_vuln_types_separate() {
1335 let records = vec![
1336 record(
1337 "SQL Injection on QUERY_STRING",
1338 "critical",
1339 "http://h/search?q=",
1340 ),
1341 record(
1342 "LDAP Injection on QUERY_STRING",
1343 "critical",
1344 "http://h/search?q=",
1345 ),
1346 ];
1347 let groups = group_findings(&records);
1348 assert_eq!(groups.len(), 2);
1349 }
1350
1351 #[test]
1352 fn infers_server_and_framework() {
1353 let records = vec![record("Trace.axd exposed", "medium", "http://h/trace.axd")];
1354 assert_eq!(infer_server(&records).as_deref(), Some("Microsoft IIS 8.5"));
1355 assert_eq!(infer_framework(&records).as_deref(), Some("ASP.NET"));
1356 }
1357
1358 #[test]
1359 fn risk_score_reflects_severity() {
1360 let records = vec![
1361 record(
1362 "SQL Injection on QUERY_STRING",
1363 "critical",
1364 "http://h/search?q=",
1365 ),
1366 record(
1367 "LDAP Injection on QUERY_STRING",
1368 "critical",
1369 "http://h/login?user=",
1370 ),
1371 ];
1372 let risk = compute_risk_score(&records, &[]);
1373 assert!(risk.score >= 20);
1374 assert_eq!(risk.bar.chars().count(), 10);
1375 assert!(risk
1376 .reasons
1377 .iter()
1378 .any(|reason| reason.contains("critical")));
1379 }
1380
1381 #[test]
1382 fn info_noise_never_outweighs_exploitable_vuln() {
1383 let noisy: Vec<ScanRecord> = (0..100)
1386 .map(|i| {
1387 record(
1388 &format!("Alive (200) {}", i),
1389 "info",
1390 &format!("http://h/{}", i),
1391 )
1392 })
1393 .collect();
1394 let risk_noise = compute_risk_score(&noisy, &[]);
1395 assert!(risk_noise.score < 10);
1396
1397 let mut exploitable = vec![record("Apache path traversal", "critical", "http://h/")];
1398 exploitable[0].cves = vec!["CVE-2021-41773".to_string()];
1399 exploitable[0].cve = Some("CVE-2021-41773".to_string());
1400 let risk_exploitable = compute_risk_score(&exploitable, &[]);
1401 assert!(risk_exploitable.score > risk_noise.score * 4);
1402 }
1403
1404 #[test]
1405 fn injectable_endpoints_raise_risk() {
1406 let records = vec![record("No WAF detected", "info", "http://h/")];
1407 let low = compute_risk_score(&records, &[]);
1408 let injectable = vec![InjectableEndpoint {
1409 target: "http://h/".into(),
1410 endpoint: "http://h?name=1".into(),
1411 }];
1412 let raised = compute_risk_score(&records, &injectable);
1413 assert!(raised.score > low.score);
1414 }
1415
1416 #[test]
1417 fn categories_modules() {
1418 let suggestions = vec![
1419 ModuleSuggestion {
1420 service_banner: "IIS".into(),
1421 suggested_module: "auxiliary/scanner/http/iis_version".into(),
1422 confidence: "high".into(),
1423 },
1424 ModuleSuggestion {
1425 service_banner: "robots.txt".into(),
1426 suggested_module: "auxiliary/scanner/http/robots_txt".into(),
1427 confidence: "high".into(),
1428 },
1429 ModuleSuggestion {
1430 service_banner: "tomcat".into(),
1431 suggested_module: "exploit/multi/http/tomcat_jsp_upload_bypass".into(),
1432 confidence: "high".into(),
1433 },
1434 ];
1435 let categories = categorize_modules(&suggestions);
1436 assert_eq!(categories.enumeration.len(), 1);
1437 assert_eq!(categories.validation.len(), 1);
1438 assert_eq!(categories.exploitation.len(), 1);
1439 }
1440
1441 #[test]
1442 fn no_credible_chain_without_evidence() {
1443 let records = vec![record(
1444 "SQL Injection on QUERY_STRING",
1445 "critical",
1446 "http://h/search?q=",
1447 )];
1448 let chain = build_attack_chain(&records, &[]);
1450 assert!(chain.is_none());
1451 assert!(build_attack_path(&records).is_empty());
1452 }
1453
1454 #[test]
1455 fn injectable_endpoint_builds_validation_chain() {
1456 let records = vec![record("Alive (200)", "info", "http://h/")];
1457 let injectable = vec![InjectableEndpoint {
1458 target: "http://h/".into(),
1459 endpoint: "http://h?name=1".into(),
1460 }];
1461 let chain = build_attack_chain(&records, &injectable).expect("chain from injectable");
1462 assert_eq!(chain.first().map(String::as_str), Some("Internet"));
1463 assert!(chain.iter().any(|step| step.contains("Injection Point")));
1464 assert_eq!(
1465 chain.last().map(String::as_str),
1466 Some("Manual Validation Required")
1467 );
1468 }
1469
1470 #[test]
1471 fn known_exploitable_cve_builds_rce_chain() {
1472 let mut records = vec![record("Apache path traversal", "critical", "http://h/")];
1473 records[0].cves = vec!["CVE-2021-41773".to_string()];
1474 records[0].cve = Some("CVE-2021-41773".to_string());
1475 let chain = build_attack_chain(&records, &[]).expect("chain from exploit cve");
1476 assert!(chain.iter().any(|step| step.contains("CVE-2021-41773")));
1477 assert_eq!(
1478 chain.last().map(String::as_str),
1479 Some("Remote Code Execution")
1480 );
1481 }
1482
1483 #[test]
1484 fn exploitability_assessment_flags_injectable() {
1485 let records = vec![record("No WAF detected", "info", "http://h/")];
1486 let assessment = exploitability_assessment(
1487 &records,
1488 &[InjectableEndpoint {
1489 target: "http://h/".into(),
1490 endpoint: "http://h?name=1".into(),
1491 }],
1492 );
1493 assert_eq!(assessment.injectable_count, 1);
1494 assert!(assessment.reasons.iter().any(|r| r.contains("injectable")));
1495 }
1496
1497 #[test]
1498 fn mitre_mapping_flags_injection() {
1499 let records = vec![record(
1500 "SQL Injection on QUERY_STRING",
1501 "critical",
1502 "http://h/search?q=",
1503 )];
1504 let tactics = map_mitre(&records);
1505 assert!(tactics
1506 .iter()
1507 .any(|tactic| tactic.tactic == "Credential Access"));
1508 }
1509
1510 #[test]
1511 fn endpoint_extracted_from_title() {
1512 let rec = ScanRecord {
1513 target: "h".into(),
1514 tool: "t".into(),
1515 title: "SQL Injection at /products?id=1".into(),
1516 severity: "high".into(),
1517 port: None,
1518 cve: None,
1519 service: None,
1520 raw: json!({}),
1521 ..Default::default()
1522 };
1523 assert_eq!(extract_endpoint(&rec).as_deref(), Some("/products?id=1"));
1524 }
1525}