ploit_malper/engine/
catalog.rs1use std::collections::BTreeSet;
2
3use crate::models::{MSFModule, ModuleSuggestion};
4
5pub struct LiveModuleCatalog {
17 modules: Vec<MSFModule>,
18 names: BTreeSet<String>,
19}
20
21impl LiveModuleCatalog {
22 pub fn from_modules(modules: Vec<MSFModule>) -> Self {
23 let names = modules.iter().map(|m| m.name.clone()).collect();
24 Self { modules, names }
25 }
26
27 pub fn len(&self) -> usize {
28 self.modules.len()
29 }
30
31 pub fn is_empty(&self) -> bool {
32 self.modules.is_empty()
33 }
34
35 pub fn contains(&self, full_name: &str) -> bool {
36 self.names.contains(full_name)
37 }
38
39 pub fn all(&self) -> &[MSFModule] {
40 &self.modules
41 }
42
43 pub fn suggest_for_banner(&self, banner: &str) -> Vec<ModuleSuggestion> {
46 self.match_banner(banner, "high", banner.to_string())
47 }
48
49 pub fn suggest_for_title(&self, title: &str, details: &str) -> Vec<ModuleSuggestion> {
52 let combined = format!("{} {}", title, details);
53 self.match_banner(&combined, "medium", "title/details match".to_string())
54 }
55
56 pub fn modules_for_cve(&self, cve: &str) -> Vec<MSFModule> {
64 let needle = cve.trim().to_uppercase();
65 let needle_key = needle.replace('-', "_").to_lowercase();
66 let mut seen = BTreeSet::new();
67 let mut found = Vec::new();
68
69 for module in &self.modules {
70 if !is_suggestable(&module.name) {
71 continue;
72 }
73 let name_key = module.name.to_lowercase();
74 if name_key.contains(&needle_key) && seen.insert(module.name.clone()) {
75 found.push(module.clone());
76 }
77 }
78
79 for module in crate::msf::modules_for_cve(&needle) {
80 if self.names.contains(&module.name) && seen.insert(module.name.clone()) {
81 found.push(module);
82 }
83 }
84
85 found.sort_by(|a, b| a.name.cmp(&b.name));
86 found
87 }
88
89 fn match_banner(&self, text: &str, confidence: &str, source: String) -> Vec<ModuleSuggestion> {
90 let haystack = text.to_lowercase();
91 let mut seen = BTreeSet::new();
92 let mut suggestions = Vec::new();
93 for module in &self.modules {
94 if !is_suggestable(&module.name) {
95 continue;
96 }
97 if leaf_matches(&module.name, &haystack) && seen.insert(module.name.clone()) {
98 suggestions.push(ModuleSuggestion {
99 service_banner: source.clone(),
100 suggested_module: module.name.clone(),
101 confidence: confidence.to_string(),
102 });
103 }
104 }
105 suggestions
106 }
107}
108
109fn is_suggestable(full_name: &str) -> bool {
112 full_name.starts_with("exploit/") || full_name.starts_with("auxiliary/")
113}
114
115fn leaf_matches(module_name: &str, haystack: &str) -> bool {
120 let leaf = module_name.rsplit('/').next().unwrap_or(module_name);
121 leaf.split(['_', '-'])
122 .any(|token| token.len() >= 3 && haystack.contains(token))
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn module(name: &str) -> MSFModule {
130 MSFModule {
131 name: name.to_string(),
132 cve: None,
133 rank: "normal".to_string(),
134 disclosure_date: "unknown".to_string(),
135 platforms: Vec::new(),
136 required_options: Vec::new(),
137 }
138 }
139
140 fn sample_catalog() -> LiveModuleCatalog {
143 LiveModuleCatalog::from_modules(vec![
144 module("auxiliary/scanner/http/robots_txt"),
145 module("auxiliary/scanner/http/apache_version"),
146 module("auxiliary/scanner/http/http_version"),
147 module("auxiliary/scanner/ssh/ssh_version"),
148 module("auxiliary/scanner/mysql/mysql_version"),
149 module("auxiliary/scanner/smb/smb_version"),
150 module("exploit/windows/smb/ms17_010_eternalblue"),
151 module("exploit/windows/rdp/cve_2019_0708_bluekeep"),
152 module("post/multi/gather/env"),
153 ])
154 }
155
156 #[test]
157 fn banner_match_finds_existing_modules() {
158 let catalog = sample_catalog();
159 let suggestions = catalog.suggest_for_banner("Apache/2.4.49");
160 assert!(suggestions
161 .iter()
162 .any(|s| s.suggested_module == "auxiliary/scanner/http/apache_version"));
163 assert_eq!(
164 suggestions[0].confidence, "high",
165 "banner matches carry high confidence"
166 );
167 }
168
169 #[test]
170 fn banner_match_skips_non_existent_modules() {
171 let catalog = sample_catalog();
172 let suggestions = catalog.suggest_for_banner("nginx/1.25");
175 assert!(
176 !suggestions
177 .iter()
178 .any(|s| s.suggested_module.contains("nginx_version")),
179 "non-existent module must never be suggested"
180 );
181 }
182
183 #[test]
184 fn banner_match_finds_ssh_for_openssh_banner() {
185 let catalog = sample_catalog();
186 let suggestions = catalog.suggest_for_banner("OpenSSH/8.9p1");
187 assert!(suggestions
188 .iter()
189 .any(|s| s.suggested_module == "auxiliary/scanner/ssh/ssh_version"));
190 }
191
192 #[test]
193 fn title_match_finds_robots_txt() {
194 let catalog = sample_catalog();
195 let suggestions = catalog.suggest_for_title("robots.txt exposed", "");
196 assert!(suggestions
197 .iter()
198 .any(|s| s.suggested_module == "auxiliary/scanner/http/robots_txt"));
199 assert_eq!(
200 suggestions[0].confidence, "medium",
201 "title matches carry medium confidence"
202 );
203 }
204
205 #[test]
206 fn post_modules_are_never_suggested() {
207 let catalog = sample_catalog();
208 let suggestions = catalog.suggest_for_banner("environment variables");
209 assert!(
210 !suggestions
211 .iter()
212 .any(|s| s.suggested_module.starts_with("post/")),
213 "post modules operate on sessions and must not be host suggestions"
214 );
215 }
216
217 #[test]
218 fn cve_lookup_filters_offline_candidates_through_live_list() {
219 let catalog = sample_catalog();
220 let modules = catalog.modules_for_cve("CVE-2021-41773");
221 assert!(
224 !modules
225 .iter()
226 .any(|m| m.name.contains("apache_path_traversal")),
227 "offline-only candidates must be filtered out when not present live"
228 );
229 }
230
231 #[test]
232 fn cve_lookup_surfaces_name_embedded_cves() {
233 let catalog = sample_catalog();
234 let modules = catalog.modules_for_cve("CVE-2019-0708");
235 assert!(modules
236 .iter()
237 .any(|m| m.name == "exploit/windows/rdp/cve_2019_0708_bluekeep"));
238 }
239
240 #[test]
241 fn leaf_matching_ignores_short_tokens() {
242 assert!(!leaf_matches("auxiliary/scanner/ssh/ssh_version", "xsh"));
245 }
246}