Skip to main content

ploit_malper/
analyze.rs

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
30// ---------------------------------------------------------------------------
31// Endpoint extraction
32// ---------------------------------------------------------------------------
33
34fn 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
134/// Extract a normalized endpoint (path + query) from a record if possible.
135pub 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
145// ---------------------------------------------------------------------------
146// Smart grouping
147// ---------------------------------------------------------------------------
148
149const 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
174/// Classify a record into an intelligence family plus a specific sub-item.
175///
176/// Returns `(family_title, specific_detail)`. The family collapses noisy
177/// scanner output (e.g. all missing security headers), while the specific
178/// detail preserves exactly what was reported for traceability.
179pub 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
293/// Title-case a security header name preserving its hyphenation and leading
294/// acronyms: `content-security-policy` -> `Content-Security-Policy`,
295/// `x-content-type-options` -> `X-Content-Type-Options`.
296fn 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
369/// Group records into intelligence families and merge evidence.
370pub 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
437// ---------------------------------------------------------------------------
438// Server / framework inference
439// ---------------------------------------------------------------------------
440
441fn 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
576// ---------------------------------------------------------------------------
577// Vulnerability detection
578// ---------------------------------------------------------------------------
579
580const 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
706// ---------------------------------------------------------------------------
707// Risk score
708// ---------------------------------------------------------------------------
709
710/// Weighted risk scoring. Severity carries the baseline, but actionable
711/// intelligence (CVSS, public exploits, injectable endpoints, known CVEs,
712/// exposed services) dominates. Large piles of INFO/LOW noise are capped so
713/// they can never outweigh a single exploitable vulnerability.
714pub fn compute_risk_score(records: &[ScanRecord], injectable: &[InjectableEndpoint]) -> RiskScore {
715    let mut score: f64 = 0.0;
716
717    // Severity baseline; INFO is heavily discounted and capped.
718    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    // CVSS v3 drives the score when present (fall back to v2 at half weight).
737    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    // Public exploit / KEV availability for discovered CVEs.
755    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    // Injectable endpoints are high-value intelligence.
773    let injectable_count = injectable.len();
774    score += (injectable_count as f64 * 8.0).min(40.0);
775
776    // Exposed services add reconnaissance value.
777    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
859// ---------------------------------------------------------------------------
860// Exploitability assessment
861// ---------------------------------------------------------------------------
862
863/// Assess how likely the discovered surface is to be exploitable, based purely
864/// on evidence: known exploits/KEV for discovered CVEs and injectable points.
865pub 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
941// ---------------------------------------------------------------------------
942// Metasploit module categorization
943// ---------------------------------------------------------------------------
944
945pub 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
980/// Group Metasploit modules by intent: version/banner discovery is
981/// enumeration, content/behaviour checks are validation, and `exploit/`
982/// modules are potential exploitation.
983fn 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
995/// Flag application-specific vulnerabilities that have no generic Metasploit
996/// module so the report can call them out for manual validation instead of
997/// inventing a module.
998pub 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
1050// ---------------------------------------------------------------------------
1051// Attack chain
1052// ---------------------------------------------------------------------------
1053
1054/// Build a credible attack chain from evidence only. Returns `None` when no
1055/// chain can be justified, so the report can state that explicitly instead of
1056/// inventing one.
1057///
1058/// Supported chains:
1059/// * Injectable endpoint flagged by the scanner.
1060/// * Publicly known exploitable CVE (public exploit, KEV, or Metasploit module).
1061pub 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
1097/// Backward-compatible path builder; returns an empty vector when no credible
1098/// chain exists.
1099pub fn build_attack_path(records: &[ScanRecord]) -> Vec<String> {
1100    build_attack_chain(records, &[]).unwrap_or_default()
1101}
1102
1103// ---------------------------------------------------------------------------
1104// MITRE ATT&CK mapping
1105// ---------------------------------------------------------------------------
1106
1107const 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    // Web scanning is always present when any scan data was processed.
1229    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// ---------------------------------------------------------------------------
1281// Helpers
1282// ---------------------------------------------------------------------------
1283
1284#[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        // A pile of informational findings must stay below a single
1384        // critical finding with a known public exploit.
1385        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        // No injectable endpoint and no known exploitable CVE → no chain.
1449        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}