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