Skip to main content

ploit_malper/exploit/
planner.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::db::models::{Asset, Finding, ScanRun, Service};
4use crate::db::Storage;
5use crate::engine::matcher;
6use crate::error::{AppError, Result};
7use crate::msf;
8
9use super::models::*;
10
11/// One actionable (asset, finding, service, cve) tuple gathered for a run.
12#[derive(Debug, Clone)]
13pub struct ExploitTarget {
14    pub asset: Asset,
15    pub finding: Finding,
16    pub service: Option<Service>,
17    pub cve: Option<String>,
18}
19
20/// Plan discovery: turns a run into a list of module steps that could be
21/// executed against its hosts. Works entirely offline against the database
22/// and the local module catalogs; live framework verification happens later.
23pub struct Planner;
24
25impl Planner {
26    /// Resolve a run id that may be a full `stable_id` or a unique prefix.
27    pub fn resolve_run(storage: &mut dyn Storage, run_ref: &str) -> Result<ScanRun> {
28        if let Some(run) = storage.get_scan_run(run_ref)? {
29            return Ok(run);
30        }
31        let runs = storage.list_scan_runs()?;
32        let matches: Vec<&ScanRun> = runs
33            .iter()
34            .filter(|run| run.stable_id.starts_with(run_ref))
35            .collect();
36        match matches.len() {
37            1 => Ok(matches[0].clone()),
38            0 => Err(AppError::Message(format!(
39                "no scan run matches '{}'",
40                run_ref
41            ))),
42            _ => Err(AppError::Message(format!(
43                "run id '{}' is ambiguous ({} matches); use a longer prefix",
44                run_ref,
45                matches.len()
46            ))),
47        }
48    }
49
50    /// Collect the assets that belong to a run. A run's primary target usually
51    /// matches the asset name; additionally any asset referenced by an
52    /// observation recorded under this run id is included.
53    pub fn collect_assets(storage: &mut dyn Storage, run: &ScanRun) -> Result<Vec<Asset>> {
54        let mut seen = BTreeSet::new();
55        let mut assets = Vec::new();
56
57        for asset in storage.list_assets()? {
58            let matches_target = asset.name.eq_ignore_ascii_case(&run.target)
59                || asset
60                    .ip
61                    .as_deref()
62                    .is_some_and(|ip| ip.eq_ignore_ascii_case(&run.target))
63                || asset
64                    .fqdn
65                    .as_deref()
66                    .is_some_and(|fqdn| fqdn.eq_ignore_ascii_case(&run.target));
67            if matches_target && seen.insert(asset.stable_id.clone()) {
68                assets.push(asset);
69            }
70        }
71
72        for observation in storage.list_all_observations()? {
73            if observation.run_id == run.stable_id
74                && observation.subject_type == "asset"
75                && seen.insert(observation.subject_id.clone())
76            {
77                if let Some(asset) = storage.get_asset(&observation.subject_id)? {
78                    assets.push(asset);
79                }
80            }
81        }
82
83        Ok(assets)
84    }
85
86    /// Gather every (asset, finding, service, cve) tuple for a run.
87    pub fn collect_targets(storage: &mut dyn Storage, run: &ScanRun) -> Result<Vec<ExploitTarget>> {
88        let mut targets = Vec::new();
89        for asset in Self::collect_assets(storage, run)? {
90            let services = storage.list_services_for_asset(&asset.stable_id)?;
91            for finding in storage.list_findings_for_asset(&asset.stable_id)? {
92                if finding.status == crate::db::models::ASSET_REMOVED {
93                    continue;
94                }
95                let service = finding
96                    .service_id
97                    .as_deref()
98                    .and_then(|id| services.iter().find(|s| s.stable_id == id))
99                    .cloned();
100                let cves = finding.cves.clone();
101                if cves.is_empty() {
102                    targets.push(ExploitTarget {
103                        asset: asset.clone(),
104                        finding: finding.clone(),
105                        service: service.clone(),
106                        cve: None,
107                    });
108                } else {
109                    for cve in cves {
110                        targets.push(ExploitTarget {
111                            asset: asset.clone(),
112                            finding: finding.clone(),
113                            service: service.clone(),
114                            cve: Some(cve),
115                        });
116                    }
117                }
118            }
119        }
120        Ok(targets)
121    }
122
123    /// Build the exploitation plan: every module mapped to any target, deduped
124    /// by (module, host). `--module` / `--target` filters apply here.
125    pub fn build_plan(
126        storage: &mut dyn Storage,
127        run: &ScanRun,
128        module_filter: Option<&str>,
129        target_filter: Option<&str>,
130    ) -> Result<Vec<ExploitStep>> {
131        let targets = Self::collect_targets(storage, run)?;
132        if targets.is_empty() {
133            return Err(AppError::Message(format!(
134                "run '{}' (target={}) has no active findings to exploit",
135                run.stable_id, run.target
136            )));
137        }
138
139        let mut steps = Vec::new();
140        let mut seen = BTreeSet::new();
141
142        for target in &targets {
143            if let Some(filter) = target_filter {
144                let hay = format!(
145                    "{} {} {}",
146                    target.asset.name,
147                    target.asset.ip.clone().unwrap_or_default(),
148                    target.asset.fqdn.clone().unwrap_or_default()
149                )
150                .to_lowercase();
151                if !hay.contains(&filter.to_lowercase()) {
152                    continue;
153                }
154            }
155
156            // Assemble the full suggestion set for this target. CVE maps,
157            // banner matches, technology/CPE matches and title matches each
158            // contribute independently; the union is deduped so a single CVE
159            // module never silently crowds out title/technology candidates.
160            let mut module_names: Vec<String> = Vec::new();
161            if let Some(cve) = &target.cve {
162                for module in msf::modules_for_cve(cve) {
163                    module_names.push(module.name.clone());
164                }
165                if let Some(info) = crate::enrich::enrich_cve(cve) {
166                    module_names.extend(info.metasploit_modules);
167                }
168            }
169            if let Some(service) = &target.service {
170                let banner = service
171                    .banner
172                    .clone()
173                    .unwrap_or_else(|| service.product.clone().unwrap_or_default());
174                if !banner.is_empty() {
175                    for suggestion in matcher::suggest_modules(&banner) {
176                        module_names.push(suggestion.suggested_module);
177                    }
178                }
179                let techs: Vec<String> = service
180                    .technologies
181                    .iter()
182                    .map(|t| t.to_lowercase())
183                    .collect();
184                if !techs.is_empty() {
185                    for module in msf::modules_for_technology(&techs) {
186                        module_names.push(module.name);
187                    }
188                }
189            }
190            module_names.extend(
191                matcher::suggest_from_title(
192                    &target.finding.title,
193                    target.finding.detail.as_deref().unwrap_or(""),
194                )
195                .into_iter()
196                .map(|s| s.suggested_module),
197            );
198
199            module_names.sort();
200            module_names.dedup();
201            if let Some(filter) = module_filter {
202                module_names.retain(|name| name.contains(filter));
203            }
204
205            for name in module_names {
206                let key = (name.clone(), target.asset.name.clone());
207                if !seen.insert(key) {
208                    continue;
209                }
210                let (module_type, module_leaf) = split_module_name(&name);
211                let confidence = if target.cve.is_some() {
212                    "high"
213                } else {
214                    "medium"
215                };
216                let rank = msf::modules_for_cve(target.cve.as_deref().unwrap_or(""))
217                    .iter()
218                    .find(|m| m.name == name)
219                    .map(|m| m.rank.clone())
220                    .unwrap_or_else(|| {
221                        if module_type == MODULE_AUXILIARY {
222                            "normal".to_string()
223                        } else {
224                            "good".to_string()
225                        }
226                    });
227                let inferred = infer_options(target, module_type);
228                let required = required_option_hint(&name, module_type);
229                let missing = required
230                    .iter()
231                    .filter(|opt| !inferred.contains_key(*opt))
232                    .cloned()
233                    .collect::<Vec<_>>();
234
235                steps.push(ExploitStep {
236                    module_type: module_type.to_string(),
237                    module_name: name.clone(),
238                    cve: target.cve.clone(),
239                    vulnerability: target.finding.title.clone(),
240                    rank,
241                    asset_id: target.asset.stable_id.clone(),
242                    finding_id: target.finding.stable_id.clone(),
243                    host: host_for(&target.asset),
244                    service_port: target.service.as_ref().map(|s| s.port),
245                    service_protocol: target.service.as_ref().map(|s| s.protocol.clone()),
246                    inferred_options: inferred.clone(),
247                    missing_options: missing.clone(),
248                    payload_compatibility: payload_hint(&name, module_type),
249                    confidence: confidence.to_string(),
250                    default_selected: module_type == MODULE_EXPLOIT && missing.is_empty(),
251                });
252                let _ = module_leaf;
253            }
254        }
255
256        if steps.is_empty() {
257            return Err(AppError::Message(
258                "no Metasploit modules could be mapped to this run's findings".to_string(),
259            ));
260        }
261
262        steps.sort_by(|a, b| {
263            a.module_name
264                .cmp(&b.module_name)
265                .then_with(|| a.host.cmp(&b.host))
266        });
267        Ok(steps)
268    }
269}
270
271fn split_module_name(name: &str) -> (&str, &str) {
272    match name.split_once('/') {
273        Some((first, rest)) if first == "exploit" || first == "auxiliary" => (first, rest),
274        _ => (MODULE_EXPLOIT, name),
275    }
276}
277
278fn host_for(asset: &Asset) -> String {
279    if let Some(ip) = asset.ip.as_deref().filter(|ip| !ip.is_empty()) {
280        return ip.to_string();
281    }
282    if let Some(fqdn) = asset.fqdn.as_deref().filter(|f| !f.is_empty()) {
283        return fqdn.to_string();
284    }
285    asset.name.clone()
286}
287
288/// Infer options from database records for a target.
289fn infer_options(target: &ExploitTarget, module_type: &str) -> BTreeMap<String, String> {
290    let mut options = BTreeMap::new();
291    let host = host_for(&target.asset);
292    options.insert("RHOSTS".to_string(), host.clone());
293
294    if let Some(service) = &target.service {
295        options.insert("RPORT".to_string(), service.port.to_string());
296        let protocol = service.protocol.to_lowercase();
297        if protocol.contains("ssl") || protocol.contains("https") {
298            options.insert("SSL".to_string(), "true".to_string());
299        }
300        if module_type == MODULE_EXPLOIT || protocol.contains("http") {
301            if let Some(fqdn) = target.asset.fqdn.as_deref().filter(|f| !f.is_empty()) {
302                options.insert("VHOST".to_string(), fqdn.to_string());
303                options.insert("HTTPHOST".to_string(), fqdn.to_string());
304            } else {
305                options.insert("HTTPHOST".to_string(), host.clone());
306            }
307        }
308    }
309
310    if let Some(target_url) = &target.finding.target_url {
311        let without_query = target_url.split('?').next().unwrap_or(target_url);
312        let path = if let Some(scheme_end) = without_query.find("://") {
313            let after_scheme = &without_query[scheme_end + 3..];
314            match after_scheme.find('/') {
315                Some(i) => after_scheme[i..].to_string(),
316                None => String::new(),
317            }
318        } else {
319            without_query.to_string()
320        };
321        let cleaned = path
322            .trim_end_matches('/')
323            .chars()
324            .take(512)
325            .collect::<String>();
326        if !cleaned.is_empty() {
327            options.insert("TARGETURI".to_string(), cleaned);
328        }
329    }
330
331    options
332}
333
334/// Required options that cannot be inferred from the database for this module.
335fn required_option_hint(name: &str, module_type: &str) -> Vec<String> {
336    let mut required = vec!["RHOSTS".to_string()];
337    if name.contains("http") || name.contains("webapp") || name.contains("struts") {
338        required.push("TARGETURI".to_string());
339    }
340    if name.contains("cve_2019_0708")
341        || name.contains("zerologon")
342        || name.contains("smb")
343        || name.contains("rdp")
344        || name.contains("ssh_version")
345        || name.contains("mysql_version")
346        || name.contains("postgres_version")
347        || name.contains("ftp_version")
348    {
349        required.push("RPORT".to_string());
350    }
351    if module_type == MODULE_EXPLOIT {
352        required.push("LHOST".to_string());
353    }
354    required.dedup();
355    required
356}
357
358/// A short, honest payload compatibility hint shown in the plan.
359fn payload_hint(name: &str, module_type: &str) -> String {
360    if module_type == MODULE_AUXILIARY {
361        return "n/a (auxiliary — no payload)".to_string();
362    }
363    if name.contains("windows") {
364        "windows/meterpreter/reverse_tcp (LHOST required)".to_string()
365    } else if name.contains("linux") || name.contains("unix") {
366        "linux/x64/meterpreter/reverse_tcp (LHOST required)".to_string()
367    } else {
368        "generic/shell_reverse_tcp (LHOST required)".to_string()
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::db::models::{Asset, Finding, ScanRun, Service};
376
377    fn seed_storage() -> crate::db::sqlite::SqliteStorage {
378        let mut storage = crate::db::sqlite::SqliteStorage::open(":memory:").unwrap();
379        let now = crate::db::models::now_utc();
380
381        let mut run = ScanRun::new("run123", "10.0.0.5", "/tmp/scan", "abc", None);
382        run.finished_at = Some(now.clone());
383        storage.upsert_scan_run(&run).unwrap();
384
385        let mut asset = Asset::new("10.0.0.5", "ip");
386        asset.ip = Some("10.0.0.5".to_string());
387        storage.upsert_asset(&asset).unwrap();
388
389        let mut service = Service::new(&asset.stable_id, 80, "tcp", "http");
390        service.banner = Some("Apache/2.4.49".to_string());
391        storage.upsert_service(&service).unwrap();
392
393        let mut finding = Finding::new(&asset.stable_id, "vulnmalper", "Apache path traversal");
394        finding.service_id = Some(service.stable_id.clone());
395        finding.cves = vec!["CVE-2021-41773".to_string()];
396        finding.target_url = Some("https://10.0.0.5/cgi-bin/".to_string());
397        finding.severity = "high".to_string();
398        storage.upsert_finding(&finding).unwrap();
399
400        storage
401    }
402
403    #[test]
404    fn resolve_run_by_prefix() {
405        let mut storage = seed_storage();
406        let run = Planner::resolve_run(&mut storage, "run12").unwrap();
407        assert_eq!(run.stable_id, "run123");
408        assert_eq!(run.target, "10.0.0.5");
409    }
410
411    #[test]
412    fn collect_assets_matches_run_target() {
413        let mut storage = seed_storage();
414        let run = Planner::resolve_run(&mut storage, "run123").unwrap();
415        let assets = Planner::collect_assets(&mut storage, &run).unwrap();
416        assert_eq!(assets.len(), 1);
417        assert_eq!(assets[0].name, "10.0.0.5");
418    }
419
420    #[test]
421    fn build_plan_maps_cve_to_module() {
422        let mut storage = seed_storage();
423        let run = Planner::resolve_run(&mut storage, "run123").unwrap();
424        let plan = Planner::build_plan(&mut storage, &run, None, None).unwrap();
425
426        let step = plan
427            .iter()
428            .find(|s| s.module_name == "exploit/multi/http/apache_path_traversal")
429            .expect("apache_path_traversal should be planned");
430        assert_eq!(step.cve.as_deref(), Some("CVE-2021-41773"));
431        assert_eq!(step.host, "10.0.0.5");
432        assert_eq!(step.service_port, Some(80));
433        assert_eq!(step.confidence, "high");
434        assert_eq!(
435            step.inferred_options.get("RHOSTS").map(String::as_str),
436            Some("10.0.0.5")
437        );
438        assert_eq!(
439            step.inferred_options.get("RPORT").map(String::as_str),
440            Some("80")
441        );
442        assert_eq!(
443            step.inferred_options.get("TARGETURI").map(String::as_str),
444            Some("/cgi-bin")
445        );
446    }
447
448    #[test]
449    fn build_plan_applies_module_filter() {
450        let mut storage = seed_storage();
451        let run = Planner::resolve_run(&mut storage, "run123").unwrap();
452        let plan = Planner::build_plan(&mut storage, &run, Some("apache_path"), None).unwrap();
453        assert!(!plan.is_empty());
454        assert!(plan.iter().all(|s| s.module_name.contains("apache_path")));
455    }
456
457    #[test]
458    fn build_plan_keeps_the_full_suggestion_set_per_target() {
459        // A CVE-driven module must not crowd out banner/title candidates for
460        // the same target: the plan surfaces every suggestion, deduped.
461        let mut storage = seed_storage();
462        let run = Planner::resolve_run(&mut storage, "run123").unwrap();
463        let plan = Planner::build_plan(&mut storage, &run, None, None).unwrap();
464
465        let by_name: std::collections::HashMap<String, &ExploitStep> =
466            plan.iter().map(|s| (s.module_name.clone(), s)).collect();
467
468        assert!(by_name.contains_key("exploit/multi/http/apache_path_traversal"));
469        // The Apache banner also maps to a scanner; the single CVE module must
470        // not cause it (or its sibling exploit) to be dropped.
471        assert!(by_name.contains_key("auxiliary/scanner/http/apache_version"));
472        assert!(by_name.contains_key("exploit/multi/http/apache_mod_cgi_bash_env_exec"));
473        assert_eq!(by_name.len(), 3);
474    }
475
476    #[test]
477    fn build_plan_cve_free_target_uses_banner_and_title() {
478        let mut storage = seed_storage();
479        let now = crate::db::models::now_utc();
480
481        let mut asset = Asset::new("10.1.1.1", "ip");
482        asset.ip = Some("10.1.1.1".to_string());
483        storage.upsert_asset(&asset).unwrap();
484
485        let mut service = Service::new(&asset.stable_id, 22, "tcp", "ssh");
486        service.banner = Some("OpenSSH_8.2p1".to_string());
487        storage.upsert_service(&service).unwrap();
488
489        let mut finding = Finding::new(&asset.stable_id, "vulnmalper", "OpenSSH banner disclosure");
490        finding.service_id = Some(service.stable_id.clone());
491        finding.severity = "low".to_string();
492        storage.upsert_finding(&finding).unwrap();
493
494        let mut run =
495            crate::db::models::ScanRun::new("runfree", "10.1.1.1", "/tmp/free", "hashfree", None);
496        run.finished_at = Some(now);
497        storage.upsert_scan_run(&run).unwrap();
498
499        let plan = Planner::build_plan(&mut storage, &run, None, None).unwrap();
500        let names: Vec<&str> = plan.iter().map(|s| s.module_name.as_str()).collect();
501        // No CVE in play; only the openssh banner/title suggestions should
502        // surface, and all of them, not a single one.
503        assert!(names.contains(&"auxiliary/scanner/ssh/ssh_version"));
504        assert_eq!(plan.len(), 1, "expected exactly the ssh_version suggestion");
505    }
506
507    #[test]
508    fn delete_scan_run_removes_run_and_owned_records_only() {
509        let mut storage = seed_storage();
510        let run = Planner::resolve_run(&mut storage, "run123").unwrap();
511        let asset = storage.list_assets().unwrap().remove(0);
512
513        let obs = crate::db::models::Observation::new(
514            &run.stable_id,
515            "asset",
516            &asset.stable_id,
517            "asset_discovered",
518            serde_json::json!({}),
519            serde_json::json!({"ip": "10.0.0.5"}),
520            "discovered",
521        );
522        storage.add_observation(&obs).unwrap();
523
524        let exec = crate::db::models::ExploitExecution::new(
525            &run.stable_id,
526            &asset.stable_id,
527            "vuln-1",
528            "exploit/multi/http/apache_path_traversal",
529        );
530        storage.upsert_exploit_execution(&exec).unwrap();
531
532        assert_eq!(storage.list_all_observations().unwrap().len(), 1);
533        assert_eq!(storage.list_exploit_executions().unwrap().len(), 1);
534
535        storage.delete_scan_run(&run.stable_id).unwrap();
536
537        assert!(storage.get_scan_run(&run.stable_id).unwrap().is_none());
538        assert_eq!(storage.list_all_observations().unwrap().len(), 0);
539        assert_eq!(storage.list_exploit_executions().unwrap().len(), 0);
540        assert_eq!(storage.list_assets().unwrap().len(), 1);
541        assert_eq!(storage.list_all_services().unwrap().len(), 1);
542        assert_eq!(storage.list_all_findings().unwrap().len(), 1);
543    }
544
545    #[test]
546    fn host_prefers_ip() {
547        let mut asset = Asset::new("host.example", "hostname");
548        asset.ip = Some("192.168.1.10".to_string());
549        assert_eq!(host_for(&asset), "192.168.1.10");
550    }
551}