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();
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
288fn 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
334fn 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
358fn 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 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 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 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}