Skip to main content

gossan_classify/
rules.rs

1//! TOML-defined service classification rules.
2//!
3//! Each rule matches a banner pattern and extracts service metadata.
4//! Rules are loaded from `rules/` directory, user-extensible.
5
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::Path;
9
10/// A single service classification rule.
11#[derive(Debug, Clone, Deserialize)]
12pub struct ServiceRule {
13    /// Unique rule identifier (e.g., "http-apache").
14    pub id: String,
15    /// Human-readable service name (e.g., "Apache HTTP Server").
16    pub service: String,
17    /// Protocol this rule applies to (tcp/udp).
18    pub protocol: String,
19    /// Ports this rule commonly matches (hint, not filter).
20    pub common_ports: Vec<u16>,
21    /// Banner patterns to match (any match triggers the rule).
22    pub patterns: Vec<String>,
23    /// Regex for version extraction (capture group 1 = version).
24    pub version_pattern: Option<String>,
25    /// Security signals this service may emit.
26    pub security_signals: Vec<String>,
27    /// Priority (higher = preferred when multiple rules match).
28    pub priority: u8,
29}
30
31#[derive(Debug, Deserialize)]
32struct ServiceRulesFile {
33    #[serde(default)]
34    rule: Vec<ServiceRule>,
35}
36
37/// Load custom service-classification rules from a TOML file.
38///
39/// Schema: a `[[rule]]` array with each entry shaped like
40/// [`ServiceRule`]. Returns the parsed rules or an `io::Error` /
41/// `toml::de::Error` wrapped in `anyhow::Error`.
42///
43/// ```toml
44/// [[rule]]
45/// id = "custom-service"
46/// service = "MyService"
47/// protocol = "tcp"
48/// common_ports = [9999]
49/// patterns = ["MYSERVICE/"]
50/// version_pattern = "MYSERVICE/(\\d+\\.\\d+)"
51/// security_signals = []
52/// priority = 5
53/// ```
54pub fn load_from_toml<P: AsRef<Path>>(path: P) -> Result<Vec<ServiceRule>, anyhow::Error> {
55    let body = std::fs::read_to_string(path.as_ref())?;
56    let parsed: ServiceRulesFile = toml::from_str(&body)?;
57    Ok(parsed.rule)
58}
59
60/// Load custom rules + the built-in set, in that order. Custom
61/// rules take precedence (appear first) so a higher-priority custom
62/// rule can override a built-in.
63pub fn builtin_plus<P: AsRef<Path>>(custom_path: P) -> Vec<ServiceRule> {
64    let mut out = load_from_toml(custom_path).unwrap_or_default();
65    out.extend(builtin_rules());
66    out
67}
68
69/// Result of classifying a banner.
70#[derive(Debug, Clone)]
71pub struct ServiceMatch {
72    /// The rule that matched.
73    pub rule_id: String,
74    /// Identified service name.
75    pub service: String,
76    /// Extracted version, if available.
77    pub version: Option<String>,
78    /// Confidence score (0.0 - 1.0).
79    pub confidence: f32,
80    /// Security signals detected in the banner.
81    pub signals: Vec<String>,
82    /// Additional metadata extracted.
83    pub metadata: HashMap<String, String>,
84}
85
86/// Built-in rule definitions.
87///
88/// These cover the most common services. Users extend via TOML files
89/// in a `rules/` directory.
90pub fn builtin_rules() -> Vec<ServiceRule> {
91    let mut out = vec![
92        // ── HTTP servers ──────────────────────────────────────────────
93        ServiceRule {
94            id: "http-apache".into(),
95            service: "Apache HTTP Server".into(),
96            protocol: "tcp".into(),
97            common_ports: vec![80, 443, 8080, 8443],
98            patterns: vec![
99                "Apache/".into(),
100                "Server: Apache".into(),
101            ],
102            version_pattern: Some(r"Apache/(\d+\.\d+\.\d+)".into()),
103            security_signals: vec![
104                "server-version-disclosure".into(),
105            ],
106            priority: 10,
107        },
108        ServiceRule {
109            id: "http-nginx".into(),
110            service: "nginx".into(),
111            protocol: "tcp".into(),
112            common_ports: vec![80, 443, 8080, 8443],
113            patterns: vec![
114                "nginx/".into(),
115                "Server: nginx".into(),
116            ],
117            version_pattern: Some(r"nginx/(\d+\.\d+\.\d+)".into()),
118            security_signals: vec![
119                "server-version-disclosure".into(),
120            ],
121            priority: 10,
122        },
123        ServiceRule {
124            id: "http-iis".into(),
125            service: "Microsoft IIS".into(),
126            protocol: "tcp".into(),
127            common_ports: vec![80, 443, 8080],
128            patterns: vec![
129                "Microsoft-IIS/".into(),
130                "Server: Microsoft-IIS".into(),
131            ],
132            version_pattern: Some(r"Microsoft-IIS/(\d+\.\d+)".into()),
133            security_signals: vec![
134                "server-version-disclosure".into(),
135            ],
136            priority: 10,
137        },
138        // ── SSH ───────────────────────────────────────────────────────
139        ServiceRule {
140            id: "ssh-openssh".into(),
141            service: "OpenSSH".into(),
142            protocol: "tcp".into(),
143            common_ports: vec![22, 2222],
144            patterns: vec![
145                "SSH-2.0-OpenSSH".into(),
146                "SSH-1.99-OpenSSH".into(),
147            ],
148            version_pattern: Some(r"OpenSSH[_\s](\d+\.\d+\S*)".into()),
149            security_signals: vec![
150                "ssh-version-disclosure".into(),
151            ],
152            priority: 10,
153        },
154        ServiceRule {
155            id: "ssh-dropbear".into(),
156            service: "Dropbear SSH".into(),
157            protocol: "tcp".into(),
158            common_ports: vec![22, 2222],
159            patterns: vec!["dropbear".into()],
160            version_pattern: Some(r"dropbear[_\s](\d+\.\d+)".into()),
161            security_signals: vec!["ssh-version-disclosure".into()],
162            priority: 8,
163        },
164        // ── FTP ───────────────────────────────────────────────────────
165        ServiceRule {
166            id: "ftp-vsftpd".into(),
167            service: "vsftpd".into(),
168            protocol: "tcp".into(),
169            common_ports: vec![21],
170            patterns: vec!["vsftpd".into(), "vsFTPd".into()],
171            version_pattern: Some(r"vsftpd\s+(\d+\.\d+\.\d+)".into()),
172            security_signals: vec!["ftp-version-disclosure".into()],
173            priority: 10,
174        },
175        ServiceRule {
176            id: "ftp-proftpd".into(),
177            service: "ProFTPD".into(),
178            protocol: "tcp".into(),
179            common_ports: vec![21],
180            patterns: vec!["ProFTPD".into()],
181            version_pattern: Some(r"ProFTPD\s+(\d+\.\d+\.\d+\S*)".into()),
182            security_signals: vec!["ftp-version-disclosure".into()],
183            priority: 10,
184        },
185        // ── Databases ─────────────────────────────────────────────────
186        ServiceRule {
187            id: "mysql".into(),
188            service: "MySQL".into(),
189            protocol: "tcp".into(),
190            common_ports: vec![3306],
191            patterns: vec![
192                "mysql_native_password".into(),
193                "MariaDB".into(),
194                "\x00\x00\x00\x0a".into(), // MySQL greeting
195            ],
196            version_pattern: Some(r"(\d+\.\d+\.\d+)".into()),
197            security_signals: vec![
198                "database-exposed".into(),
199                "version-disclosure".into(),
200            ],
201            priority: 10,
202        },
203        ServiceRule {
204            id: "postgresql".into(),
205            service: "PostgreSQL".into(),
206            protocol: "tcp".into(),
207            common_ports: vec![5432],
208            patterns: vec!["PostgreSQL".into()],
209            version_pattern: Some(r"PostgreSQL\s+(\d+\.\d+)".into()),
210            security_signals: vec!["database-exposed".into()],
211            priority: 10,
212        },
213        ServiceRule {
214            id: "redis".into(),
215            service: "Redis".into(),
216            protocol: "tcp".into(),
217            common_ports: vec![6379],
218            patterns: vec![
219                "-ERR".into(),
220                "+PONG".into(),
221                "$".into(),
222                "redis_version:".into(),
223            ],
224            version_pattern: Some(r"redis_version:(\d+\.\d+\.\d+)".into()),
225            security_signals: vec![
226                "redis-unauthenticated".into(),
227                "database-exposed".into(),
228            ],
229            priority: 10,
230        },
231        ServiceRule {
232            id: "mongodb".into(),
233            service: "MongoDB".into(),
234            protocol: "tcp".into(),
235            common_ports: vec![27017],
236            patterns: vec!["ismaster".into(), "MongoDB".into()],
237            version_pattern: Some(r"(\d+\.\d+\.\d+)".into()),
238            security_signals: vec!["database-exposed".into()],
239            priority: 10,
240        },
241        // ── Mail ──────────────────────────────────────────────────────
242        ServiceRule {
243            id: "smtp".into(),
244            service: "SMTP".into(),
245            protocol: "tcp".into(),
246            common_ports: vec![25, 465, 587],
247            patterns: vec![
248                "220 ".into(),
249                "ESMTP".into(),
250                "Postfix".into(),
251                "Exim".into(),
252            ],
253            version_pattern: Some(r"(?:Postfix|Exim|Sendmail)\s*(\d+\.\d+\S*)".into()),
254            security_signals: vec!["smtp-open-relay-check".into()],
255            priority: 8,
256        },
257        // ── Elastic / Kibana ──────────────────────────────────────────
258        ServiceRule {
259            id: "elasticsearch".into(),
260            service: "Elasticsearch".into(),
261            protocol: "tcp".into(),
262            common_ports: vec![9200, 9300],
263            patterns: vec![
264                "\"cluster_name\"".into(),
265                "\"tagline\" : \"You Know, for Search\"".into(),
266            ],
267            version_pattern: Some(r#""number"\s*:\s*"(\d+\.\d+\.\d+)""#.into()),
268            security_signals: vec![
269                "elasticsearch-unauthenticated".into(),
270                "search-engine-exposed".into(),
271            ],
272            priority: 10,
273        },
274        // ── RDP ───────────────────────────────────────────────────────
275        ServiceRule {
276            id: "rdp".into(),
277            service: "RDP".into(),
278            protocol: "tcp".into(),
279            common_ports: vec![3389],
280            patterns: vec![
281                "\x03\x00".into(), // RDP negotiation response
282            ],
283            version_pattern: None,
284            security_signals: vec!["rdp-exposed".into()],
285            priority: 10,
286        },
287    ];
288    // Append the curated top-100 expansion below to keep diffs reviewable.
289    out.extend(extended_rules());
290    out
291}
292
293fn extended_rules() -> Vec<ServiceRule> {
294    let r = |id: &str, service: &str, protocol: &str, ports: Vec<u16>,
295             patterns: Vec<&str>, version: Option<&str>,
296             signals: Vec<&str>, priority: u8| -> ServiceRule {
297        ServiceRule {
298            id: id.into(),
299            service: service.into(),
300            protocol: protocol.into(),
301            common_ports: ports,
302            patterns: patterns.into_iter().map(String::from).collect(),
303            version_pattern: version.map(String::from),
304            security_signals: signals.into_iter().map(String::from).collect(),
305            priority,
306        }
307    };
308    vec![
309        // ── HTTP servers (additional) ─────────────────────────────────
310        r("http-haproxy", "HAProxy", "tcp", vec![80, 443, 8080, 8443],
311            vec!["Server: HAProxy", "HAProxy", "X-Haproxy"],
312            Some(r"HAProxy[/\s]+(\d+\.\d+(?:\.\d+)?)"),
313            vec!["server-version-disclosure"], 9),
314        r("http-caddy", "Caddy", "tcp", vec![80, 443, 2015, 2019],
315            vec!["Server: Caddy"],
316            Some(r"Caddy/(\d+\.\d+\.\d+)"),
317            vec!["server-version-disclosure"], 10),
318        r("http-litespeed", "LiteSpeed", "tcp", vec![80, 443, 7080],
319            vec!["Server: LiteSpeed", "LiteSpeed/"],
320            Some(r"LiteSpeed[/\s](\d+\.\d+\.\d+)"),
321            vec!["server-version-disclosure"], 9),
322        r("http-tomcat", "Apache Tomcat", "tcp", vec![8080, 8443, 8005, 8009],
323            vec!["Apache-Coyote/", "Apache Tomcat"],
324            Some(r"Apache Tomcat/?(\d+\.\d+\.\d+)"),
325            vec!["servlet-container-exposed", "server-version-disclosure"], 10),
326        r("http-jetty", "Eclipse Jetty", "tcp", vec![8080, 8443],
327            vec!["Server: Jetty", "Jetty("],
328            Some(r"Jetty\(?(\d+\.\d+\.\d+)"),
329            vec!["server-version-disclosure"], 9),
330        r("http-tornado", "Tornado", "tcp", vec![8888, 8080],
331            vec!["Server: TornadoServer"],
332            Some(r"TornadoServer/(\d+\.\d+\.\d+)"),
333            vec!["server-version-disclosure"], 8),
334        r("http-gunicorn", "Gunicorn", "tcp", vec![8000, 8080],
335            vec!["Server: gunicorn"],
336            Some(r"gunicorn/(\d+\.\d+\.\d+)"),
337            vec!["server-version-disclosure"], 8),
338        r("http-werkzeug", "Werkzeug", "tcp", vec![5000, 8000],
339            vec!["Werkzeug/"],
340            Some(r"Werkzeug/(\d+\.\d+\.\d+)"),
341            vec!["debug-server-exposed", "server-version-disclosure"], 9),
342        r("http-uvicorn", "Uvicorn", "tcp", vec![8000, 8080],
343            vec!["Server: uvicorn"],
344            Some(r"uvicorn"),
345            vec!["server-version-disclosure"], 7),
346        r("http-puma", "Puma", "tcp", vec![3000, 9292],
347            vec!["Server: puma"],
348            Some(r"puma\s+(\d+\.\d+\.\d+)"),
349            vec!["server-version-disclosure"], 8),
350        r("http-unicorn", "Unicorn", "tcp", vec![3000, 8080],
351            vec!["Server: Unicorn"],
352            Some(r"Unicorn\s+(\d+\.\d+\.\d+)"),
353            vec!["server-version-disclosure"], 7),
354        r("http-node-express", "Express (Node)", "tcp", vec![3000, 8080],
355            vec!["X-Powered-By: Express"],
356            None, vec!["framework-disclosure"], 8),
357        r("http-aws-elb", "AWS ELB", "tcp", vec![80, 443],
358            vec!["Server: awselb"],
359            Some(r"awselb/(\d+\.\d+)"), vec![], 7),
360        r("http-cloudflare", "Cloudflare", "tcp", vec![80, 443],
361            vec!["Server: cloudflare", "CF-Ray:"],
362            None, vec!["cdn-fronted"], 6),
363        r("http-akamai", "Akamai", "tcp", vec![80, 443],
364            vec!["Server: AkamaiGHost", "AkamaiGHost"],
365            None, vec!["cdn-fronted"], 6),
366        r("http-fastly", "Fastly", "tcp", vec![80, 443],
367            vec!["X-Served-By: cache-", "Via: 1.1 varnish", "Fastly-Debug"],
368            None, vec!["cdn-fronted"], 5),
369        r("http-varnish", "Varnish", "tcp", vec![80, 443, 6081, 6082],
370            vec!["Via: 1.1 varnish", "X-Varnish:"],
371            Some(r"varnish\s+v?(\d+\.\d+\.\d+)"), vec![], 7),
372        r("http-traefik", "Traefik", "tcp", vec![80, 443, 8080],
373            vec!["Server: Traefik"],
374            Some(r"Traefik/(\d+\.\d+\.\d+)"), vec!["server-version-disclosure"], 8),
375        r("http-envoy", "Envoy", "tcp", vec![80, 443, 9901],
376            vec!["Server: envoy", "X-Envoy-Upstream-Service-Time"],
377            None, vec![], 7),
378
379        // ── Caches / KV ───────────────────────────────────────────────
380        r("memcached", "Memcached", "tcp", vec![11211],
381            vec!["VERSION ", "STAT pid"],
382            Some(r"VERSION\s+(\d+\.\d+\.\d+)"),
383            vec!["memcached-unauthenticated", "database-exposed"], 10),
384        r("etcd", "etcd", "tcp", vec![2379, 2380],
385            vec!["etcdserver", "/v2/keys", "/version"],
386            Some(r#""etcdserver"\s*:\s*"(\d+\.\d+\.\d+)""#),
387            vec!["etcd-exposed"], 10),
388        r("consul", "Consul", "tcp", vec![8500, 8501, 8600],
389            vec!["X-Consul-Index", "Consul Agent"],
390            Some(r"Consul/(\d+\.\d+\.\d+)"), vec!["consul-exposed"], 10),
391        r("vault", "HashiCorp Vault", "tcp", vec![8200, 8201],
392            vec!["X-Vault-", "vault/"],
393            Some(r"vault/(\d+\.\d+\.\d+)"), vec!["vault-api-exposed"], 10),
394
395        // ── Document / column DBs ─────────────────────────────────────
396        r("couchdb", "CouchDB", "tcp", vec![5984, 6984],
397            vec!["Welcome to Apache CouchDB", "couchdb"],
398            Some(r#""version"\s*:\s*"(\d+\.\d+\.\d+)""#),
399            vec!["database-exposed"], 10),
400        r("cassandra", "Cassandra", "tcp", vec![9042, 7000, 7199],
401            vec!["Apache Cassandra"],
402            Some(r"Cassandra\s+(\d+\.\d+\.\d+)"),
403            vec!["database-exposed"], 9),
404        r("clickhouse", "ClickHouse", "tcp", vec![8123, 9000, 9009],
405            vec!["X-ClickHouse-Server-Display-Name"],
406            Some(r"ClickHouse/(\d+\.\d+\.\d+)"),
407            vec!["database-exposed"], 9),
408        r("influxdb", "InfluxDB", "tcp", vec![8086, 8088],
409            vec!["X-Influxdb-Version"],
410            Some(r"X-Influxdb-Version:\s*(\d+\.\d+\.\d+)"),
411            vec!["database-exposed"], 9),
412
413        // ── Message brokers / streams ─────────────────────────────────
414        r("rabbitmq", "RabbitMQ", "tcp", vec![5672, 15672, 25672],
415            vec!["RabbitMQ", "AMQP\x00\x00"],
416            Some(r"RabbitMQ[/\s](\d+\.\d+\.\d+)"),
417            vec!["message-broker-exposed"], 10),
418        r("rabbitmq-mgmt", "RabbitMQ Management", "tcp", vec![15672],
419            vec!["RabbitMQ Management"],
420            Some(r"RabbitMQ\s+(\d+\.\d+\.\d+)"),
421            vec!["mgmt-ui-exposed"], 9),
422        r("kafka", "Kafka", "tcp", vec![9092, 9093, 9094],
423            vec!["org.apache.kafka", "broker.id"],
424            None, vec!["broker-exposed"], 9),
425        r("zookeeper", "ZooKeeper", "tcp", vec![2181, 2888, 3888],
426            vec!["zookeeper.version", "ZooKeeper"],
427            Some(r"version=(\d+\.\d+\.\d+)"),
428            vec!["zookeeper-exposed"], 10),
429        r("nats", "NATS", "tcp", vec![4222, 6222, 8222],
430            vec!["INFO {", "\"server_name\""],
431            Some(r#""version"\s*:\s*"(\d+\.\d+\.\d+)""#),
432            vec!["nats-exposed"], 9),
433        r("mosquitto", "Mosquitto MQTT", "tcp", vec![1883, 8883, 9001],
434            vec!["mosquitto"],
435            Some(r"mosquitto\s+version\s+(\d+\.\d+\.\d+)"),
436            vec!["mqtt-exposed"], 9),
437        r("pulsar", "Apache Pulsar", "tcp", vec![6650, 8080, 6651],
438            vec!["pulsar://", "Pulsar"],
439            Some(r"Pulsar\s+(\d+\.\d+\.\d+)"),
440            vec!["broker-exposed"], 8),
441
442        // ── Container & orchestration ────────────────────────────────
443        r("docker-daemon", "Docker daemon", "tcp", vec![2375, 2376],
444            vec!["Docker/", "ApiVersion", "\"DockerRootDir\""],
445            Some(r"Docker/(\d+\.\d+\.\d+)"),
446            vec!["docker-api-exposed", "rce-on-unauth"], 10),
447        r("kubernetes-api", "Kubernetes API", "tcp", vec![6443, 8443, 10250],
448            vec!["k8s.io", "/api/v1", "\"kind\":\"APIVersions\""],
449            Some(r#""gitVersion"\s*:\s*"v(\d+\.\d+\.\d+)""#),
450            vec!["k8s-api-exposed"], 10),
451        r("portainer", "Portainer", "tcp", vec![9000, 9443],
452            vec!["Portainer", "X-Portainer"],
453            Some(r"Portainer-(\d+\.\d+\.\d+)"), vec!["mgmt-ui-exposed"], 9),
454        r("rancher", "Rancher", "tcp", vec![8443, 80, 443],
455            vec!["X-Rancher-", "rancher"],
456            Some(r"rancher/(\d+\.\d+\.\d+)"), vec!["mgmt-ui-exposed"], 8),
457        r("kubelet", "kubelet", "tcp", vec![10250, 10255, 10256],
458            vec!["/runningpods", "/healthz", "kubelet"],
459            None, vec!["kubelet-exposed", "rce-on-unauth"], 10),
460
461        // ── CI/CD ────────────────────────────────────────────────────
462        r("jenkins", "Jenkins", "tcp", vec![8080, 50000, 8443],
463            vec!["X-Jenkins:", "Jenkins-Version"],
464            Some(r"X-Jenkins:\s*(\d+\.\d+(?:\.\d+)?)"),
465            vec!["ci-server-exposed"], 10),
466        r("gitlab", "GitLab", "tcp", vec![80, 443, 8081],
467            vec!["GitLab", "X-Gitlab-Meta"],
468            Some(r"GitLab/(\d+\.\d+\.\d+)"),
469            vec!["scm-server-exposed"], 9),
470        r("gitea", "Gitea", "tcp", vec![3000, 80, 443],
471            vec!["Gitea", "X-Gitea-"],
472            Some(r"Gitea/(\d+\.\d+\.\d+)"),
473            vec!["scm-server-exposed"], 9),
474        r("gerrit", "Gerrit", "tcp", vec![8080, 29418],
475            vec!["Gerrit-", "X-Gerrit-"],
476            Some(r"Gerrit/(\d+\.\d+\.\d+)"),
477            vec!["scm-server-exposed"], 8),
478        r("sonarqube", "SonarQube", "tcp", vec![9000, 9001],
479            vec!["SonarQube", "x-sonar-"],
480            Some(r"SonarQube/(\d+\.\d+\.\d+)"),
481            vec!["mgmt-ui-exposed"], 8),
482        r("nexus", "Sonatype Nexus", "tcp", vec![8081, 8443],
483            vec!["X-Nexus-", "Server: Nexus"],
484            Some(r"Nexus/(\d+\.\d+\.\d+)"),
485            vec!["registry-exposed"], 9),
486        r("artifactory", "JFrog Artifactory", "tcp", vec![8081, 8082],
487            vec!["X-JFrog-Art-", "X-Artifactory-"],
488            Some(r"Artifactory/(\d+\.\d+\.\d+)"),
489            vec!["registry-exposed"], 9),
490        r("teamcity", "JetBrains TeamCity", "tcp", vec![8111, 80, 443],
491            vec!["TeamCity", "X-TeamCity-"],
492            Some(r"TeamCity (\d+\.\d+\.\d+)"),
493            vec!["ci-server-exposed"], 8),
494        r("concourse", "Concourse CI", "tcp", vec![8080],
495            vec!["X-Concourse-", "concourse-version"],
496            Some(r"v(\d+\.\d+\.\d+)"), vec!["ci-server-exposed"], 7),
497        r("drone-ci", "Drone CI", "tcp", vec![80, 443, 8000],
498            vec!["X-Drone", "drone/"], None,
499            vec!["ci-server-exposed"], 7),
500        r("argocd", "ArgoCD", "tcp", vec![80, 443, 8080],
501            vec!["X-ArgoCD-", "Argo CD"], None,
502            vec!["mgmt-ui-exposed"], 8),
503        r("spinnaker", "Spinnaker", "tcp", vec![8084, 9000],
504            vec!["X-Spinnaker-"], None,
505            vec!["mgmt-ui-exposed"], 7),
506        r("harbor", "Harbor", "tcp", vec![80, 443, 4443],
507            vec!["X-Harbor-Version", "Harbor-Api-Version"],
508            Some(r"X-Harbor-Version:\s*v?(\d+\.\d+\.\d+)"),
509            vec!["registry-exposed"], 9),
510        r("quay", "Quay", "tcp", vec![80, 443, 8443],
511            vec!["X-Quay-", "quay-version"], None,
512            vec!["registry-exposed"], 8),
513
514        // ── Atlassian ────────────────────────────────────────────────
515        r("bamboo", "Atlassian Bamboo", "tcp", vec![8085, 80, 443],
516            vec!["X-AUSERNAME", "Bamboo"], None,
517            vec!["ci-server-exposed"], 7),
518        r("bitbucket", "Atlassian Bitbucket", "tcp", vec![7990, 7999],
519            vec!["X-AUSERNAME", "atl_bitbucket_session"], None,
520            vec!["scm-server-exposed"], 7),
521        r("confluence", "Confluence", "tcp", vec![8090, 80, 443],
522            vec!["X-Confluence-Request-Time"], None,
523            vec!["wiki-exposed"], 7),
524        r("jira", "Jira", "tcp", vec![8080, 80, 443],
525            vec!["X-AREQUESTID", "JSESSIONID", "atlassian.xsrf.token"], None,
526            vec!["issue-tracker-exposed"], 7),
527
528        // ── Observability ────────────────────────────────────────────
529        r("grafana", "Grafana", "tcp", vec![3000, 80, 443],
530            vec!["grafana_session", "X-Grafana-"],
531            Some(r"Grafana v(\d+\.\d+\.\d+)"),
532            vec!["mgmt-ui-exposed"], 9),
533        r("prometheus", "Prometheus", "tcp", vec![9090, 9093, 9100],
534            vec!["prometheus_build_info", "/api/v1/query", "Prometheus"],
535            Some(r#"version="(\d+\.\d+\.\d+)""#),
536            vec!["metrics-exposed"], 9),
537        r("alertmanager", "Alertmanager", "tcp", vec![9093, 9094],
538            vec!["Alertmanager"], Some(r"Alertmanager/(\d+\.\d+\.\d+)"),
539            vec!["mgmt-ui-exposed"], 7),
540        r("loki", "Grafana Loki", "tcp", vec![3100],
541            vec!["loki", "/loki/api/v1"], Some(r"loki/(\d+\.\d+\.\d+)"),
542            vec!["log-server-exposed"], 7),
543        r("tempo", "Grafana Tempo", "tcp", vec![3200, 4317, 9411],
544            vec!["tempo", "/api/traces"], None,
545            vec!["trace-server-exposed"], 6),
546        r("kibana", "Kibana", "tcp", vec![5601],
547            vec!["kbn-name", "kibana"],
548            Some(r#""version"\s*:\s*"(\d+\.\d+\.\d+)""#),
549            vec!["mgmt-ui-exposed"], 9),
550        r("fluentd", "Fluentd", "tcp", vec![24224, 9880],
551            vec!["fluentd"], Some(r"fluentd[/-](\d+\.\d+\.\d+)"),
552            vec!["log-server-exposed"], 7),
553        r("logstash", "Logstash", "tcp", vec![5044, 9600],
554            vec!["logstash"], Some(r"logstash[/-](\d+\.\d+\.\d+)"),
555            vec!["log-server-exposed"], 7),
556
557        // ── Auth ────────────────────────────────────────────────────
558        r("keycloak", "Keycloak", "tcp", vec![8080, 8443],
559            vec!["X-Keycloak-", "kc_restart", "keycloak"],
560            Some(r"Keycloak/(\d+\.\d+\.\d+)"),
561            vec!["auth-server-exposed"], 8),
562        r("openldap", "OpenLDAP", "tcp", vec![389, 636],
563            vec!["LDAP", "objectClass"], None,
564            vec!["directory-exposed"], 8),
565        r("freeradius", "FreeRADIUS", "udp", vec![1812, 1813],
566            vec!["FreeRADIUS"], Some(r"FreeRADIUS\s+Version\s+(\d+\.\d+\.\d+)"),
567            vec!["radius-exposed"], 7),
568
569        // ── Mail (more) ──────────────────────────────────────────────
570        r("dovecot", "Dovecot", "tcp", vec![110, 143, 993, 995],
571            vec!["Dovecot ready", "+OK Dovecot"],
572            Some(r"Dovecot\s+\(?(\d+\.\d+\.\d+)"),
573            vec!["mail-version-disclosure"], 9),
574        r("courier-imap", "Courier IMAP", "tcp", vec![143, 993],
575            vec!["Courier-IMAP"], Some(r"Courier-IMAP\s+(\d+\.\d+\.\d+)"),
576            vec!["mail-version-disclosure"], 7),
577        r("sendmail", "Sendmail", "tcp", vec![25, 587],
578            vec!["Sendmail"], Some(r"Sendmail\s+(\d+\.\d+\.\d+)"),
579            vec!["mail-version-disclosure"], 7),
580
581        // ── File transfer ────────────────────────────────────────────
582        r("filezilla-server", "FileZilla Server", "tcp", vec![21, 990],
583            vec!["FileZilla Server"],
584            Some(r"FileZilla Server\s+(\d+\.\d+\.\d+)"),
585            vec!["ftp-version-disclosure"], 8),
586        r("pure-ftpd", "Pure-FTPd", "tcp", vec![21],
587            vec!["Pure-FTPd"], Some(r"Pure-FTPd\s+(\d+\.\d+\S*)"),
588            vec!["ftp-version-disclosure"], 8),
589        r("samba", "Samba", "tcp", vec![139, 445],
590            vec!["Samba", "SMB"], Some(r"Samba\s+(\d+\.\d+\.\d+)"),
591            vec!["smb-exposed"], 9),
592        r("nfs", "NFS", "tcp", vec![2049],
593            vec!["NFS_PROGRAM"], None,
594            vec!["nfs-exposed"], 8),
595
596        // ── Remote management ────────────────────────────────────────
597        r("ipmi", "IPMI", "udp", vec![623],
598            vec!["IPMI"], None,
599            vec!["ipmi-exposed", "remote-mgmt-exposed"], 10),
600        r("ilo", "HP iLO", "tcp", vec![80, 443, 17988],
601            vec!["Server: HP-iLO", "iLO"],
602            Some(r"HP-iLO/(\d+\.\d+)"),
603            vec!["remote-mgmt-exposed"], 10),
604        r("idrac", "Dell iDRAC", "tcp", vec![80, 443, 5900],
605            vec!["iDRAC", "Dell Inc."],
606            Some(r"iDRAC[/\s](\d+)"),
607            vec!["remote-mgmt-exposed"], 10),
608        r("supermicro-ipmi", "Supermicro BMC", "tcp", vec![80, 443],
609            vec!["ATEN International", "Supermicro"], None,
610            vec!["remote-mgmt-exposed"], 9),
611
612        // ── Printing ─────────────────────────────────────────────────
613        r("ipp-cups", "IPP/CUPS", "tcp", vec![631],
614            vec!["IPP/", "CUPS"], Some(r"CUPS/(\d+\.\d+\.\d+)"),
615            vec!["printer-exposed"], 7),
616        r("jetdirect", "HP JetDirect", "tcp", vec![9100],
617            vec!["@PJL", "HP JetDirect"], None,
618            vec!["printer-exposed"], 7),
619
620        // ── Legacy / clear-text ──────────────────────────────────────
621        // Telnet IAC bytes 0xFF/0xFB/0xFD are valid in raw bytes but
622        // not in a UTF-8 str literal, so we match the post-IAC "login:"
623        // / "Password:" prompt that almost every telnet daemon emits.
624        r("telnet", "Telnet", "tcp", vec![23, 2323],
625            vec!["login: ", "Password: ", "Welcome to "],
626            None, vec!["cleartext-protocol", "telnet-exposed"], 10),
627        r("snmp", "SNMP", "udp", vec![161, 162],
628            vec!["public", "private"], None,
629            vec!["snmp-exposed"], 9),
630        r("rpcbind", "rpcbind/portmap", "tcp", vec![111],
631            vec!["rpcbind", "program vers"], None,
632            vec!["rpc-exposed"], 7),
633
634        // ── DNS servers ──────────────────────────────────────────────
635        r("powerdns", "PowerDNS", "udp", vec![53, 8081],
636            vec!["PowerDNS"], Some(r"PowerDNS\s+Authoritative\s+Server\s+(\d+\.\d+\.\d+)"),
637            vec!["dns-version-disclosure"], 9),
638        r("nsd", "NLnet NSD", "udp", vec![53],
639            vec!["NSD "], Some(r"NSD\s+(\d+\.\d+\.\d+)"),
640            vec!["dns-version-disclosure"], 8),
641        r("knot-dns", "Knot DNS", "udp", vec![53],
642            vec!["Knot DNS"], Some(r"Knot DNS\s+(\d+\.\d+\.\d+)"),
643            vec!["dns-version-disclosure"], 8),
644        r("unbound", "Unbound", "udp", vec![53],
645            vec!["unbound"], Some(r"unbound\s+(\d+\.\d+\.\d+)"),
646            vec!["dns-version-disclosure"], 7),
647        r("dnsdist", "dnsdist", "tcp", vec![53, 5199],
648            vec!["dnsdist"], Some(r"dnsdist\s+(\d+\.\d+\.\d+)"),
649            vec!["dns-version-disclosure"], 7),
650
651        // ── Time / NTP / NTS ─────────────────────────────────────────
652        r("ntp", "NTP", "udp", vec![123],
653            vec!["\x1c"], None, vec![], 6),
654
655        // ── Misc network services ────────────────────────────────────
656        r("openvpn", "OpenVPN", "udp", vec![1194, 443],
657            vec!["P_CONTROL", "OpenVPN"], None,
658            vec!["vpn-exposed"], 8),
659        r("wireguard", "WireGuard", "udp", vec![51820, 443],
660            vec!["WireGuard"], None, vec!["vpn-exposed"], 7),
661        r("pptp", "PPTP", "tcp", vec![1723],
662            vec!["pptp"], None, vec!["legacy-vpn-exposed"], 8),
663        r("strongswan", "strongSwan IPsec", "udp", vec![500, 4500],
664            vec!["strongSwan"], Some(r"strongSwan\s+(\d+\.\d+\.\d+)"),
665            vec!["vpn-exposed"], 7),
666        r("xrdp", "xrdp", "tcp", vec![3389],
667            vec!["xrdp"], Some(r"xrdp\s+(\d+\.\d+\.\d+)"),
668            vec!["rdp-exposed"], 8),
669        r("vnc", "VNC", "tcp", vec![5900, 5901, 5902],
670            vec!["RFB 003.", "RFB"],
671            Some(r"RFB 003\.(\d+)"),
672            vec!["vnc-exposed", "remote-desktop-exposed"], 9),
673
674        // ── Game / streaming / chat ──────────────────────────────────
675        r("minecraft", "Minecraft Server", "tcp", vec![25565],
676            vec!["Minecraft Server", "minecraft.net", "Vanilla 1.", "Bukkit on"],
677            None, vec!["game-server-exposed"], 5),
678        r("ts3", "TeamSpeak 3", "udp", vec![9987, 10011, 30033],
679            vec!["TS3", "TeaSpeak"], None, vec![], 5),
680        r("plex", "Plex Media Server", "tcp", vec![32400],
681            vec!["X-Plex-", "Plex Media Server"],
682            Some(r"X-Plex-Version:\s*(\d+\.\d+\.\d+)"),
683            vec!["media-server-exposed"], 6),
684        r("jellyfin", "Jellyfin", "tcp", vec![8096, 8920],
685            vec!["X-Application", "Jellyfin"],
686            Some(r"Jellyfin/(\d+\.\d+\.\d+)"),
687            vec!["media-server-exposed"], 6),
688
689        // ── Industrial / IoT (banner-detectable) ─────────────────────
690        // Modbus MBAP header is 7 bytes; the ProtocolID at offset 2 is
691        // always 0x0000 0x0000 plus a 2-byte length and unit ID. A naked
692        // run of zero bytes is too generic — gate on the typical "len=6,
693        // unit=1" prefix which catches most server responses without
694        // matching arbitrary zero-padded buffers.
695        r("modbus", "Modbus TCP", "tcp", vec![502],
696            vec!["\u{0}\u{0}\u{0}\u{0}\u{0}\u{6}\u{1}"],
697            None, vec!["ics-exposed", "modbus-exposed"], 10),
698        r("siemens-s7", "Siemens S7", "tcp", vec![102],
699            vec!["S7", "ISO-on-TCP"], None,
700            vec!["ics-exposed"], 10),
701
702        // ── Storage protocols ────────────────────────────────────────
703        r("iscsi", "iSCSI", "tcp", vec![3260],
704            vec!["iSCSI", "iqn."], None,
705            vec!["storage-exposed"], 8),
706        r("minio", "MinIO", "tcp", vec![9000, 9001],
707            vec!["X-Amz-Bucket-Region", "MinIO", "MinIO Console"],
708            Some(r"MinIO/(\S+)"), vec!["object-storage-exposed"], 9),
709
710        // ── Search / vector / AI infra ───────────────────────────────
711        r("solr", "Apache Solr", "tcp", vec![8983, 7574],
712            vec!["Solr", "X-Solr-"],
713            Some(r"Solr/(\d+\.\d+\.\d+)"),
714            vec!["search-engine-exposed"], 9),
715        r("opensearch", "OpenSearch", "tcp", vec![9200],
716            vec!["opensearch", "OpenSearch"],
717            Some(r#""number"\s*:\s*"(\d+\.\d+\.\d+)""#),
718            vec!["search-engine-exposed"], 9),
719        r("milvus", "Milvus", "tcp", vec![19530, 9091],
720            vec!["milvus"], Some(r"milvus[/\s](\d+\.\d+\.\d+)"),
721            vec!["vector-db-exposed"], 7),
722        r("qdrant", "Qdrant", "tcp", vec![6333, 6334],
723            vec!["qdrant", "Qdrant"], Some(r"Qdrant/(\d+\.\d+\.\d+)"),
724            vec!["vector-db-exposed"], 7),
725        r("weaviate", "Weaviate", "tcp", vec![8080, 50051],
726            vec!["weaviate"], Some(r"weaviate[/\s](\d+\.\d+\.\d+)"),
727            vec!["vector-db-exposed"], 7),
728
729        // ── Misc legacy headline ports ───────────────────────────────
730        r("smb1", "SMBv1", "tcp", vec![139, 445],
731            vec!["SMB 1.0"], None,
732            vec!["smbv1-exposed", "deprecated-protocol"], 10),
733        r("rsync", "rsync", "tcp", vec![873],
734            vec!["@RSYNCD:"], Some(r"@RSYNCD:\s*(\d+\.\d+)"),
735            vec!["rsync-exposed"], 8),
736    ]
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    #[test]
744    fn builtin_rules_not_empty() {
745        let rules = builtin_rules();
746        assert!(rules.len() > 10, "should have substantial rule coverage");
747    }
748
749    #[test]
750    fn all_rules_have_unique_ids() {
751        let rules = builtin_rules();
752        let mut seen = std::collections::HashSet::new();
753        for rule in &rules {
754            assert!(seen.insert(&rule.id), "duplicate rule id: {}", rule.id);
755        }
756    }
757
758    #[test]
759    fn all_rules_have_patterns() {
760        for rule in builtin_rules() {
761            assert!(!rule.patterns.is_empty(), "rule {} has no patterns", rule.id);
762        }
763    }
764}