use std::collections::BTreeSet;
use crate::models::{MSFModule, ModuleSuggestion};
pub struct LiveModuleCatalog {
modules: Vec<MSFModule>,
names: BTreeSet<String>,
}
impl LiveModuleCatalog {
pub fn from_modules(modules: Vec<MSFModule>) -> Self {
let names = modules.iter().map(|m| m.name.clone()).collect();
Self { modules, names }
}
pub fn len(&self) -> usize {
self.modules.len()
}
pub fn is_empty(&self) -> bool {
self.modules.is_empty()
}
pub fn contains(&self, full_name: &str) -> bool {
self.names.contains(full_name)
}
pub fn all(&self) -> &[MSFModule] {
&self.modules
}
pub fn suggest_for_banner(&self, banner: &str) -> Vec<ModuleSuggestion> {
self.match_banner(banner, "high", banner.to_string())
}
pub fn suggest_for_title(&self, title: &str, details: &str) -> Vec<ModuleSuggestion> {
let combined = format!("{} {}", title, details);
self.match_banner(&combined, "medium", "title/details match".to_string())
}
pub fn modules_for_cve(&self, cve: &str) -> Vec<MSFModule> {
let needle = cve.trim().to_uppercase();
let needle_key = needle.replace('-', "_").to_lowercase();
let mut seen = BTreeSet::new();
let mut found = Vec::new();
for module in &self.modules {
if !is_suggestable(&module.name) {
continue;
}
let name_key = module.name.to_lowercase();
if name_key.contains(&needle_key) && seen.insert(module.name.clone()) {
found.push(module.clone());
}
}
for module in crate::msf::modules_for_cve(&needle) {
if self.names.contains(&module.name) && seen.insert(module.name.clone()) {
found.push(module);
}
}
found.sort_by(|a, b| a.name.cmp(&b.name));
found
}
fn match_banner(&self, text: &str, confidence: &str, source: String) -> Vec<ModuleSuggestion> {
let haystack = text.to_lowercase();
let mut seen = BTreeSet::new();
let mut suggestions = Vec::new();
for module in &self.modules {
if !is_suggestable(&module.name) {
continue;
}
if leaf_matches(&module.name, &haystack) && seen.insert(module.name.clone()) {
suggestions.push(ModuleSuggestion {
service_banner: source.clone(),
suggested_module: module.name.clone(),
confidence: confidence.to_string(),
});
}
}
suggestions
}
}
fn is_suggestable(full_name: &str) -> bool {
full_name.starts_with("exploit/") || full_name.starts_with("auxiliary/")
}
fn leaf_matches(module_name: &str, haystack: &str) -> bool {
let leaf = module_name.rsplit('/').next().unwrap_or(module_name);
leaf.split(['_', '-'])
.any(|token| token.len() >= 3 && haystack.contains(token))
}
#[cfg(test)]
mod tests {
use super::*;
fn module(name: &str) -> MSFModule {
MSFModule {
name: name.to_string(),
cve: None,
rank: "normal".to_string(),
disclosure_date: "unknown".to_string(),
platforms: Vec::new(),
required_options: Vec::new(),
}
}
fn sample_catalog() -> LiveModuleCatalog {
LiveModuleCatalog::from_modules(vec![
module("auxiliary/scanner/http/robots_txt"),
module("auxiliary/scanner/http/apache_version"),
module("auxiliary/scanner/http/http_version"),
module("auxiliary/scanner/ssh/ssh_version"),
module("auxiliary/scanner/mysql/mysql_version"),
module("auxiliary/scanner/smb/smb_version"),
module("exploit/windows/smb/ms17_010_eternalblue"),
module("exploit/windows/rdp/cve_2019_0708_bluekeep"),
module("post/multi/gather/env"),
])
}
#[test]
fn banner_match_finds_existing_modules() {
let catalog = sample_catalog();
let suggestions = catalog.suggest_for_banner("Apache/2.4.49");
assert!(suggestions
.iter()
.any(|s| s.suggested_module == "auxiliary/scanner/http/apache_version"));
assert_eq!(
suggestions[0].confidence, "high",
"banner matches carry high confidence"
);
}
#[test]
fn banner_match_skips_non_existent_modules() {
let catalog = sample_catalog();
let suggestions = catalog.suggest_for_banner("nginx/1.25");
assert!(
!suggestions
.iter()
.any(|s| s.suggested_module.contains("nginx_version")),
"non-existent module must never be suggested"
);
}
#[test]
fn banner_match_finds_ssh_for_openssh_banner() {
let catalog = sample_catalog();
let suggestions = catalog.suggest_for_banner("OpenSSH/8.9p1");
assert!(suggestions
.iter()
.any(|s| s.suggested_module == "auxiliary/scanner/ssh/ssh_version"));
}
#[test]
fn title_match_finds_robots_txt() {
let catalog = sample_catalog();
let suggestions = catalog.suggest_for_title("robots.txt exposed", "");
assert!(suggestions
.iter()
.any(|s| s.suggested_module == "auxiliary/scanner/http/robots_txt"));
assert_eq!(
suggestions[0].confidence, "medium",
"title matches carry medium confidence"
);
}
#[test]
fn post_modules_are_never_suggested() {
let catalog = sample_catalog();
let suggestions = catalog.suggest_for_banner("environment variables");
assert!(
!suggestions
.iter()
.any(|s| s.suggested_module.starts_with("post/")),
"post modules operate on sessions and must not be host suggestions"
);
}
#[test]
fn cve_lookup_filters_offline_candidates_through_live_list() {
let catalog = sample_catalog();
let modules = catalog.modules_for_cve("CVE-2021-41773");
assert!(
!modules
.iter()
.any(|m| m.name.contains("apache_path_traversal")),
"offline-only candidates must be filtered out when not present live"
);
}
#[test]
fn cve_lookup_surfaces_name_embedded_cves() {
let catalog = sample_catalog();
let modules = catalog.modules_for_cve("CVE-2019-0708");
assert!(modules
.iter()
.any(|m| m.name == "exploit/windows/rdp/cve_2019_0708_bluekeep"));
}
#[test]
fn leaf_matching_ignores_short_tokens() {
assert!(!leaf_matches("auxiliary/scanner/ssh/ssh_version", "xsh"));
}
}