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#[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
20pub struct Planner;
24
25impl Planner {
26 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 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 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 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
276fn 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
322fn 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
346fn 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 host_prefers_ip() {
447 let mut asset = Asset::new("host.example", "hostname");
448 asset.ip = Some("192.168.1.10".to_string());
449 assert_eq!(host_for(&asset), "192.168.1.10");
450 }
451}