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 std::collections::HashMap;
7use crate::rules::{ServiceMatch, ServiceRule};
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 { rules, version_regexes }
28    }
29
30    /// Match a banner against all rules.
31    ///
32    /// Returns all matching rules sorted by priority (highest first).
33    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            // Extract version if we have a regex
48            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            // Compute confidence based on specificity
58            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            // Detect security signals
66            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    /// Batch-match multiple banners. Returns one result set per banner.
85    pub fn match_batch(&self, banners: &[&str]) -> Vec<Vec<ServiceMatch>> {
86        banners.iter().map(|b| self.match_banner(b)).collect()
87    }
88}
89
90/// Detect security-relevant signals in a banner.
91fn detect_security_signals(banner: &str, rule_signals: &[String]) -> Vec<String> {
92    let mut signals = Vec::new();
93    let bl = banner.to_lowercase();
94
95    // Always check for generic security signals
96    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    // Add rule-specific signals
110    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()); // SSH
219        assert!(!results[1].is_empty()); // nginx
220        assert!(results[2].is_empty()); // unknown
221    }
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            // Both should match, but versioned banner should have higher confidence
231            assert!(
232                with_version[0].confidence >= without_detail[0].confidence,
233                "version match should have >= confidence"
234            );
235        }
236    }
237}