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            let mut module_names: Vec<String> = Vec::new();
157            if let Some(cve) = &target.cve {
158                for module in msf::modules_for_cve(cve) {
159                    module_names.push(module.name.clone());
160                }
161                if let Some(info) = crate::enrich::enrich_cve(cve) {
162                    module_names.extend(info.metasploit_modules);
163                }
164            }
165            if let Some(service) = &target.service {
166                let banner = service
167                    .banner
168                    .clone()
169                    .unwrap_or_else(|| service.product.clone().unwrap_or_default());
170                if !banner.is_empty() {
171                    for suggestion in matcher::suggest_modules(&banner) {
172                        module_names.push(suggestion.suggested_module);
173                    }
174                }
175            }
176            if module_names.is_empty() {
177                module_names.extend(
178                    matcher::suggest_from_title(
179                        &target.finding.title,
180                        target.finding.detail.as_deref().unwrap_or(""),
181                    )
182                    .into_iter()
183                    .map(|s| s.suggested_module),
184                );
185            }
186
187            module_names.sort();
188            module_names.dedup();
189            if let Some(filter) = module_filter {
190                module_names.retain(|name| name.contains(filter));
191            }
192
193            for name in module_names {
194                let key = (name.clone(), target.asset.name.clone());
195                if !seen.insert(key) {
196                    continue;
197                }
198                let (module_type, module_leaf) = split_module_name(&name);
199                let confidence = if target.cve.is_some() {
200                    "high"
201                } else {
202                    "medium"
203                };
204                let rank = msf::modules_for_cve(target.cve.as_deref().unwrap_or(""))
205                    .iter()
206                    .find(|m| m.name == name)
207                    .map(|m| m.rank.clone())
208                    .unwrap_or_else(|| {
209                        if module_type == MODULE_AUXILIARY {
210                            "normal".to_string()
211                        } else {
212                            "good".to_string()
213                        }
214                    });
215                let inferred = infer_options(target, module_type);
216                let required = required_option_hint(&name, module_type);
217                let missing = required
218                    .iter()
219                    .filter(|opt| !inferred.contains_key(*opt))
220                    .cloned()
221                    .collect::<Vec<_>>();
222
223                steps.push(ExploitStep {
224                    module_type: module_type.to_string(),
225                    module_name: name.clone(),
226                    cve: target.cve.clone(),
227                    vulnerability: target.finding.title.clone(),
228                    rank,
229                    asset_id: target.asset.stable_id.clone(),
230                    finding_id: target.finding.stable_id.clone(),
231                    host: host_for(&target.asset),
232                    service_port: target.service.as_ref().map(|s| s.port),
233                    service_protocol: target.service.as_ref().map(|s| s.protocol.clone()),
234                    inferred_options: inferred.clone(),
235                    missing_options: missing.clone(),
236                    payload_compatibility: payload_hint(&name, module_type),
237                    confidence: confidence.to_string(),
238                    default_selected: module_type == MODULE_EXPLOIT && missing.is_empty(),
239                });
240                let _ = module_leaf;
241            }
242        }
243
244        if steps.is_empty() {
245            return Err(AppError::Message(
246                "no Metasploit modules could be mapped to this run's findings".to_string(),
247            ));
248        }
249
250        steps.sort_by(|a, b| {
251            a.module_name
252                .cmp(&b.module_name)
253                .then_with(|| a.host.cmp(&b.host))
254        });
255        Ok(steps)
256    }
257}
258
259fn split_module_name(name: &str) -> (&str, &str) {
260    match name.split_once('/') {
261        Some((first, rest)) if first == "exploit" || first == "auxiliary" => (first, rest),
262        _ => (MODULE_EXPLOIT, name),
263    }
264}
265
266fn host_for(asset: &Asset) -> String {
267    if let Some(ip) = asset.ip.as_deref().filter(|ip| !ip.is_empty()) {
268        return ip.to_string();
269    }
270    if let Some(fqdn) = asset.fqdn.as_deref().filter(|f| !f.is_empty()) {
271        return fqdn.to_string();
272    }
273    asset.name.clone()
274}
275
276/// Infer options from database records for a target.
277fn infer_options(target: &ExploitTarget, module_type: &str) -> BTreeMap<String, String> {
278    let mut options = BTreeMap::new();
279    let host = host_for(&target.asset);
280    options.insert("RHOSTS".to_string(), host.clone());
281
282    if let Some(service) = &target.service {
283        options.insert("RPORT".to_string(), service.port.to_string());
284        let protocol = service.protocol.to_lowercase();
285        if protocol.contains("ssl") || protocol.contains("https") {
286            options.insert("SSL".to_string(), "true".to_string());
287        }
288        if module_type == MODULE_EXPLOIT || protocol.contains("http") {
289            if let Some(fqdn) = target.asset.fqdn.as_deref().filter(|f| !f.is_empty()) {
290                options.insert("VHOST".to_string(), fqdn.to_string());
291                options.insert("HTTPHOST".to_string(), fqdn.to_string());
292            } else {
293                options.insert("HTTPHOST".to_string(), host.clone());
294            }
295        }
296    }
297
298    if let Some(target_url) = &target.finding.target_url {
299        let without_query = target_url.split('?').next().unwrap_or(target_url);
300        let path = if let Some(scheme_end) = without_query.find("://") {
301            let after_scheme = &without_query[scheme_end + 3..];
302            match after_scheme.find('/') {
303                Some(i) => after_scheme[i..].to_string(),
304                None => String::new(),
305            }
306        } else {
307            without_query.to_string()
308        };
309        let cleaned = path
310            .trim_end_matches('/')
311            .chars()
312            .take(512)
313            .collect::<String>();
314        if !cleaned.is_empty() {
315            options.insert("TARGETURI".to_string(), cleaned);
316        }
317    }
318
319    options
320}
321
322/// Required options that cannot be inferred from the database for this module.
323fn required_option_hint(name: &str, module_type: &str) -> Vec<String> {
324    let mut required = vec!["RHOSTS".to_string()];
325    if name.contains("http") || name.contains("webapp") || name.contains("struts") {
326        required.push("TARGETURI".to_string());
327    }
328    if name.contains("cve_2019_0708")
329        || name.contains("zerologon")
330        || name.contains("smb")
331        || name.contains("rdp")
332        || name.contains("ssh_version")
333        || name.contains("mysql_version")
334        || name.contains("postgres_version")
335        || name.contains("ftp_version")
336    {
337        required.push("RPORT".to_string());
338    }
339    if module_type == MODULE_EXPLOIT {
340        required.push("LHOST".to_string());
341    }
342    required.dedup();
343    required
344}
345
346/// A short, honest payload compatibility hint shown in the plan.
347fn payload_hint(name: &str, module_type: &str) -> String {
348    if module_type == MODULE_AUXILIARY {
349        return "n/a (auxiliary — no payload)".to_string();
350    }
351    if name.contains("windows") {
352        "windows/meterpreter/reverse_tcp (LHOST required)".to_string()
353    } else if name.contains("linux") || name.contains("unix") {
354        "linux/x64/meterpreter/reverse_tcp (LHOST required)".to_string()
355    } else {
356        "generic/shell_reverse_tcp (LHOST required)".to_string()
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::db::models::{Asset, Finding, ScanRun, Service};
364
365    fn seed_storage() -> crate::db::sqlite::SqliteStorage {
366        let mut storage = crate::db::sqlite::SqliteStorage::open(":memory:").unwrap();
367        let now = crate::db::models::now_utc();
368
369        let mut run = ScanRun::new("run123", "10.0.0.5", "/tmp/scan", "abc", None);
370        run.finished_at = Some(now.clone());
371        storage.upsert_scan_run(&run).unwrap();
372
373        let mut asset = Asset::new("10.0.0.5", "ip");
374        asset.ip = Some("10.0.0.5".to_string());
375        storage.upsert_asset(&asset).unwrap();
376
377        let mut service = Service::new(&asset.stable_id, 80, "tcp", "http");
378        service.banner = Some("Apache/2.4.49".to_string());
379        storage.upsert_service(&service).unwrap();
380
381        let mut finding = Finding::new(&asset.stable_id, "vulnmalper", "Apache path traversal");
382        finding.service_id = Some(service.stable_id.clone());
383        finding.cves = vec!["CVE-2021-41773".to_string()];
384        finding.target_url = Some("https://10.0.0.5/cgi-bin/".to_string());
385        finding.severity = "high".to_string();
386        storage.upsert_finding(&finding).unwrap();
387
388        storage
389    }
390
391    #[test]
392    fn resolve_run_by_prefix() {
393        let mut storage = seed_storage();
394        let run = Planner::resolve_run(&mut storage, "run12").unwrap();
395        assert_eq!(run.stable_id, "run123");
396        assert_eq!(run.target, "10.0.0.5");
397    }
398
399    #[test]
400    fn collect_assets_matches_run_target() {
401        let mut storage = seed_storage();
402        let run = Planner::resolve_run(&mut storage, "run123").unwrap();
403        let assets = Planner::collect_assets(&mut storage, &run).unwrap();
404        assert_eq!(assets.len(), 1);
405        assert_eq!(assets[0].name, "10.0.0.5");
406    }
407
408    #[test]
409    fn build_plan_maps_cve_to_module() {
410        let mut storage = seed_storage();
411        let run = Planner::resolve_run(&mut storage, "run123").unwrap();
412        let plan = Planner::build_plan(&mut storage, &run, None, None).unwrap();
413
414        let step = plan
415            .iter()
416            .find(|s| s.module_name == "exploit/multi/http/apache_path_traversal")
417            .expect("apache_path_traversal should be planned");
418        assert_eq!(step.cve.as_deref(), Some("CVE-2021-41773"));
419        assert_eq!(step.host, "10.0.0.5");
420        assert_eq!(step.service_port, Some(80));
421        assert_eq!(step.confidence, "high");
422        assert_eq!(
423            step.inferred_options.get("RHOSTS").map(String::as_str),
424            Some("10.0.0.5")
425        );
426        assert_eq!(
427            step.inferred_options.get("RPORT").map(String::as_str),
428            Some("80")
429        );
430        assert_eq!(
431            step.inferred_options.get("TARGETURI").map(String::as_str),
432            Some("/cgi-bin")
433        );
434    }
435
436    #[test]
437    fn build_plan_applies_module_filter() {
438        let mut storage = seed_storage();
439        let run = Planner::resolve_run(&mut storage, "run123").unwrap();
440        let plan = Planner::build_plan(&mut storage, &run, Some("apache_path"), None).unwrap();
441        assert!(!plan.is_empty());
442        assert!(plan.iter().all(|s| s.module_name.contains("apache_path")));
443    }
444
445    #[test]
446    fn delete_scan_run_removes_run_and_owned_records_only() {
447        let mut storage = seed_storage();
448        let run = Planner::resolve_run(&mut storage, "run123").unwrap();
449        let asset = storage.list_assets().unwrap().remove(0);
450
451        let obs = crate::db::models::Observation::new(
452            &run.stable_id,
453            "asset",
454            &asset.stable_id,
455            "asset_discovered",
456            serde_json::json!({}),
457            serde_json::json!({"ip": "10.0.0.5"}),
458            "discovered",
459        );
460        storage.add_observation(&obs).unwrap();
461
462        let exec = crate::db::models::ExploitExecution::new(
463            &run.stable_id,
464            &asset.stable_id,
465            "vuln-1",
466            "exploit/multi/http/apache_path_traversal",
467        );
468        storage.upsert_exploit_execution(&exec).unwrap();
469
470        assert_eq!(storage.list_all_observations().unwrap().len(), 1);
471        assert_eq!(storage.list_exploit_executions().unwrap().len(), 1);
472
473        storage.delete_scan_run(&run.stable_id).unwrap();
474
475        assert!(storage.get_scan_run(&run.stable_id).unwrap().is_none());
476        assert_eq!(storage.list_all_observations().unwrap().len(), 0);
477        assert_eq!(storage.list_exploit_executions().unwrap().len(), 0);
478        assert_eq!(storage.list_assets().unwrap().len(), 1);
479        assert_eq!(storage.list_all_services().unwrap().len(), 1);
480        assert_eq!(storage.list_all_findings().unwrap().len(), 1);
481    }
482
483    #[test]
484    fn host_prefers_ip() {
485        let mut asset = Asset::new("host.example", "hostname");
486        asset.ip = Some("192.168.1.10".to_string());
487        assert_eq!(host_for(&asset), "192.168.1.10");
488    }
489}