Skip to main content

ploit_malper/engine/
catalog.rs

1use std::collections::BTreeSet;
2
3use crate::models::{MSFModule, ModuleSuggestion};
4
5/// A module catalog built from a live Metasploit RPC listing
6/// (`module.exploits`, `module.auxiliary`, `module.post`).
7///
8/// Unlike the static offline catalogs, the source of truth here is the
9/// connected instance: a module is only ever suggested if it actually exists
10/// on that instance, so names that do not exist (e.g. a hypothetical
11/// `auxiliary/scanner/http/nginx_version`) can never be recommended.
12///
13/// Banner/title matching is derived from the module names themselves — the
14/// significant tokens of each module's leaf name are matched against the
15/// banner text — so no module names are hardcoded here.
16pub 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    /// Suggest modules for a service banner. Confidence is high because the
44    /// match is against the banner observed for a live service.
45    pub fn suggest_for_banner(&self, banner: &str) -> Vec<ModuleSuggestion> {
46        self.match_banner(banner, "high", banner.to_string())
47    }
48
49    /// Suggest modules for a finding title/details pair. Confidence is medium
50    /// because the match is against free text rather than a live banner.
51    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    /// Modules known for a CVE.
57    ///
58    /// The listing responses carry names only (no references), so the offline
59    /// catalog is used as the candidate source and every candidate is filtered
60    /// through the live names. Modules whose name embeds the CVE (e.g.
61    /// `exploit/windows/rdp/cve_2019_0708_bluekeep`) are surfaced directly
62    /// from the listing even when they are absent from the offline catalog.
63    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
109/// Only host-facing modules are suggested: exploits and auxiliary modules.
110/// Post modules operate on already-obtained sessions and are excluded.
111fn is_suggestable(full_name: &str) -> bool {
112    full_name.starts_with("exploit/") || full_name.starts_with("auxiliary/")
113}
114
115/// True when any significant token of the module's leaf name appears in the
116/// banner text. Tokens shorter than three characters are ignored so generic
117/// one-letter fragments (and the shared `version` suffix on every scanner)
118/// cannot cause spurious matches on their own.
119fn 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    /// A representative live listing (as returned by module.exploits /
141    /// module.auxiliary / module.post) used by the tests below.
142    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        // nginx_version does not exist on the (simulated) instance, so even a
173        // "nginx" banner must not produce a suggestion for it.
174        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        // The offline catalog maps this CVE to apache_path_traversal, but the
222        // simulated instance does not load it, so nothing should be returned.
223        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        // The token "sh" (from ssh) is too short to match on its own, and the
243        // shared "version" token must not cause every *_version module to match.
244        assert!(!leaf_matches("auxiliary/scanner/ssh/ssh_version", "xsh"));
245    }
246}