Skip to main content

gossan_classify/
matcher.rs

1//! Pattern matcher for banner classification.
2//!
3//! CPU-based implementation using substring search + regex version extraction.
4//! This is the fallback when Vyre GPU acceleration is not available.
5
6use crate::rules::{ServiceMatch, ServiceRule};
7use std::collections::HashMap;
8
9/// CPU-based banner pattern matcher.
10pub struct CpuMatcher {
11    rules: Vec<ServiceRule>,
12    /// Compiled regexes for version extraction (lazily compiled).
13    version_regexes: HashMap<String, Option<regex_lite::Regex>>,
14}
15
16impl CpuMatcher {
17    /// Create a new matcher with the given rules.
18    #[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    /// Match a banner against all rules.
34    ///
35    /// Returns all matching rules sorted by priority (highest first).
36    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            // Extract version if we have a regex
51            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            // Compute confidence based on specificity
62            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            // Detect security signals
71            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    /// Batch-match multiple banners. Returns one result set per banner.
92    pub fn match_batch(&self, banners: &[&str]) -> Vec<Vec<ServiceMatch>> {
93        banners.iter().map(|b| self.match_banner(b)).collect()
94    }
95}
96
97/// Detect security-relevant signals in a banner.
98fn detect_security_signals(banner: &str, rule_signals: &[String]) -> Vec<String> {
99    let mut signals = Vec::new();
100    let bl = banner.to_lowercase();
101
102    // Always check for generic security signals
103    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    // Add rule-specific signals
117    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()); // SSH
226        assert!(!results[1].is_empty()); // nginx
227        assert!(results[2].is_empty()); // unknown
228    }
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            // Both should match, but versioned banner should have higher confidence
238            assert!(
239                with_version[0].confidence >= without_detail[0].confidence,
240                "version match should have >= confidence"
241            );
242        }
243    }
244}