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    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
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"(?: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
130/// Extract a normalized endpoint (path + query) from a record if possible.
131pub 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
141// ---------------------------------------------------------------------------
142// Smart grouping
143// ---------------------------------------------------------------------------
144
145fn 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
198/// Group records by (target, normalized vulnerability) and merge locations.
199pub 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
251// ---------------------------------------------------------------------------
252// Server / framework inference
253// ---------------------------------------------------------------------------
254
255fn 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
384// ---------------------------------------------------------------------------
385// Vulnerability detection
386// ---------------------------------------------------------------------------
387
388const 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
514// ---------------------------------------------------------------------------
515// Risk score
516// ---------------------------------------------------------------------------
517
518pub 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
557// ---------------------------------------------------------------------------
558// Metasploit module categorization
559// ---------------------------------------------------------------------------
560
561pub 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
596/// Group Metasploit modules by intent: version/banner discovery is
597/// enumeration, content/behaviour checks are validation, and `exploit/`
598/// modules are potential exploitation.
599fn 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
611/// Flag application-specific vulnerabilities that have no generic Metasploit
612/// module so the report can call them out for manual validation instead of
613/// inventing a module.
614pub 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
666// ---------------------------------------------------------------------------
667// Attack path
668// ---------------------------------------------------------------------------
669
670pub 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
723// ---------------------------------------------------------------------------
724// MITRE ATT&CK mapping
725// ---------------------------------------------------------------------------
726
727const 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    // Web scanning is always present when any scan data was processed.
849    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// ---------------------------------------------------------------------------
901// Helpers
902// ---------------------------------------------------------------------------
903
904#[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}