gossan_classify/
matcher.rs1use std::collections::HashMap;
7use crate::rules::{ServiceMatch, ServiceRule};
8
9pub struct CpuMatcher {
11 rules: Vec<ServiceRule>,
12 version_regexes: HashMap<String, Option<regex_lite::Regex>>,
14}
15
16impl CpuMatcher {
17 #[must_use]
19 pub fn new(rules: Vec<ServiceRule>) -> Self {
20 let mut version_regexes = HashMap::new();
21 for rule in &rules {
22 if let Some(pattern) = &rule.version_pattern {
23 let re = regex_lite::Regex::new(pattern).ok();
24 version_regexes.insert(rule.id.clone(), re);
25 }
26 }
27 Self { rules, version_regexes }
28 }
29
30 pub fn match_banner(&self, banner: &str) -> Vec<ServiceMatch> {
34 let mut matches = Vec::new();
35 let banner_lower = banner.to_lowercase();
36
37 for rule in &self.rules {
38 let matched = rule.patterns.iter().any(|p| {
39 let p_lower = p.to_lowercase();
40 banner_lower.contains(&p_lower)
41 });
42
43 if !matched {
44 continue;
45 }
46
47 let version = self.version_regexes
49 .get(&rule.id)
50 .and_then(|re| re.as_ref())
51 .and_then(|re| {
52 re.captures(banner)
53 .and_then(|caps| caps.get(1))
54 .map(|m| m.as_str().to_string())
55 });
56
57 let pattern_matches: usize = rule.patterns.iter()
59 .filter(|p| banner_lower.contains(&p.to_lowercase()))
60 .count();
61 let confidence = (pattern_matches as f32 / rule.patterns.len() as f32)
62 .min(1.0)
63 * if version.is_some() { 1.0 } else { 0.8 };
64
65 let signals = detect_security_signals(banner, &rule.security_signals);
67
68 matches.push(ServiceMatch {
69 rule_id: rule.id.clone(),
70 service: rule.service.clone(),
71 version,
72 confidence,
73 signals,
74 metadata: HashMap::new(),
75 });
76 }
77
78 matches.sort_by(|a, b| {
79 b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal)
80 });
81 matches
82 }
83
84 pub fn match_batch(&self, banners: &[&str]) -> Vec<Vec<ServiceMatch>> {
86 banners.iter().map(|b| self.match_banner(b)).collect()
87 }
88}
89
90fn detect_security_signals(banner: &str, rule_signals: &[String]) -> Vec<String> {
92 let mut signals = Vec::new();
93 let bl = banner.to_lowercase();
94
95 if bl.contains("debug") || bl.contains("stack trace") {
97 signals.push("debug-mode-enabled".into());
98 }
99 if bl.contains("default password") || bl.contains("admin:admin") {
100 signals.push("default-credentials".into());
101 }
102 if bl.contains("directory listing") || bl.contains("index of /") {
103 signals.push("directory-listing".into());
104 }
105 if bl.contains("x-powered-by") {
106 signals.push("technology-disclosure".into());
107 }
108
109 for signal in rule_signals {
111 if !signals.contains(signal) {
112 signals.push(signal.clone());
113 }
114 }
115
116 signals
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::rules::builtin_rules;
123
124 fn matcher() -> CpuMatcher {
125 CpuMatcher::new(builtin_rules())
126 }
127
128 #[test]
129 fn matches_apache() {
130 let m = matcher();
131 let results = m.match_banner("HTTP/1.1 200 OK\r\nServer: Apache/2.4.52\r\n\r\n");
132 assert!(!results.is_empty());
133 assert_eq!(results[0].service, "Apache HTTP Server");
134 assert_eq!(results[0].version.as_deref(), Some("2.4.52"));
135 }
136
137 #[test]
138 fn matches_nginx() {
139 let m = matcher();
140 let results = m.match_banner("HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\n\r\n");
141 assert!(!results.is_empty());
142 assert_eq!(results[0].service, "nginx");
143 assert_eq!(results[0].version.as_deref(), Some("1.24.0"));
144 }
145
146 #[test]
147 fn matches_openssh() {
148 let m = matcher();
149 let results = m.match_banner("SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6");
150 assert!(!results.is_empty());
151 assert_eq!(results[0].service, "OpenSSH");
152 assert_eq!(results[0].version.as_deref(), Some("8.9p1"));
153 }
154
155 #[test]
156 fn matches_redis() {
157 let m = matcher();
158 let results = m.match_banner("+PONG\r\n");
159 assert!(!results.is_empty());
160 assert_eq!(results[0].service, "Redis");
161 }
162
163 #[test]
164 fn matches_redis_version() {
165 let m = matcher();
166 let results = m.match_banner("redis_version:7.2.4\r\n");
167 assert!(!results.is_empty());
168 assert_eq!(results[0].version.as_deref(), Some("7.2.4"));
169 }
170
171 #[test]
172 fn matches_elasticsearch() {
173 let m = matcher();
174 let banner = r#"{"cluster_name":"docker-cluster","tagline":"You Know, for Search","version":{"number":"8.12.0"}}"#;
175 let results = m.match_banner(banner);
176 assert!(!results.is_empty());
177 assert_eq!(results[0].service, "Elasticsearch");
178 assert_eq!(results[0].version.as_deref(), Some("8.12.0"));
179 }
180
181 #[test]
182 fn matches_mysql() {
183 let m = matcher();
184 let results = m.match_banner("5.7.42-0ubuntu0.18.04.1\x00...mysql_native_password\x00");
185 assert!(!results.is_empty());
186 assert_eq!(results[0].service, "MySQL");
187 }
188
189 #[test]
190 fn no_match_for_unknown_banner() {
191 let m = matcher();
192 let results = m.match_banner("XYZZY UNKNOWN PROTOCOL\r\n");
193 assert!(results.is_empty());
194 }
195
196 #[test]
197 fn detects_debug_mode() {
198 let signals = detect_security_signals("Stack Trace: at foo.bar()", &[]);
199 assert!(signals.contains(&"debug-mode-enabled".to_string()));
200 }
201
202 #[test]
203 fn detects_directory_listing() {
204 let signals = detect_security_signals("<title>Index of /</title>", &[]);
205 assert!(signals.contains(&"directory-listing".to_string()));
206 }
207
208 #[test]
209 fn batch_match_works() {
210 let m = matcher();
211 let banners = vec![
212 "SSH-2.0-OpenSSH_9.0",
213 "HTTP/1.1 200 OK\r\nServer: nginx/1.25.0",
214 "totally unknown thing",
215 ];
216 let results = m.match_batch(&banners);
217 assert_eq!(results.len(), 3);
218 assert!(!results[0].is_empty()); assert!(!results[1].is_empty()); assert!(results[2].is_empty()); }
222
223 #[test]
224 fn confidence_higher_with_version() {
225 let m = matcher();
226 let with_version = m.match_banner("Server: Apache/2.4.52");
227 let without_detail = m.match_banner("Server: Apache");
228
229 if !with_version.is_empty() && !without_detail.is_empty() {
230 assert!(
232 with_version[0].confidence >= without_detail[0].confidence,
233 "version match should have >= confidence"
234 );
235 }
236 }
237}