syncable_cli/analyzer/dclint/rules/
dcl005.rs

1//! DCL005: no-unbound-port-interfaces
2//!
3//! Ports should bind to a specific interface (not 0.0.0.0).
4
5use crate::analyzer::dclint::rules::{LintContext, Rule, SimpleRule, make_failure};
6use crate::analyzer::dclint::types::{CheckFailure, RuleCategory, Severity};
7
8const CODE: &str = "DCL005";
9const NAME: &str = "no-unbound-port-interfaces";
10const DESCRIPTION: &str = "Ports should bind to a specific interface for security.";
11const URL: &str = "https://github.com/zavoloklom/docker-compose-linter/blob/main/docs/rules/no-unbound-port-interfaces-rule.md";
12
13pub fn rule() -> impl Rule {
14    SimpleRule::new(
15        CODE,
16        NAME,
17        Severity::Warning,
18        RuleCategory::Security,
19        DESCRIPTION,
20        URL,
21        check,
22    )
23}
24
25fn check(ctx: &LintContext) -> Vec<CheckFailure> {
26    let mut failures = Vec::new();
27
28    for (service_name, service) in &ctx.compose.services {
29        for port in &service.ports {
30            // Check if port has a host port but no explicit interface
31            if port.host_port.is_some() && !port.has_explicit_interface() {
32                let message = format!(
33                    "Port \"{}\" in service \"{}\" does not specify a host interface. Consider binding to 127.0.0.1 for local-only access.",
34                    port.raw, service_name
35                );
36
37                failures.push(
38                    make_failure(
39                        &CODE.into(),
40                        NAME,
41                        Severity::Warning,
42                        RuleCategory::Security,
43                        message,
44                        port.position.line,
45                        port.position.column,
46                        false,
47                    )
48                    .with_data("serviceName", service_name.clone())
49                    .with_data("port", port.raw.clone()),
50                );
51            }
52        }
53    }
54
55    failures
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::analyzer::dclint::parser::parse_compose;
62
63    fn check_yaml(yaml: &str) -> Vec<CheckFailure> {
64        let compose = parse_compose(yaml).unwrap();
65        let ctx = LintContext::new(&compose, yaml, "docker-compose.yml");
66        check(&ctx)
67    }
68
69    #[test]
70    fn test_no_violation_explicit_interface() {
71        let yaml = r#"
72services:
73  web:
74    image: nginx
75    ports:
76      - "127.0.0.1:8080:80"
77"#;
78        assert!(check_yaml(yaml).is_empty());
79    }
80
81    #[test]
82    fn test_no_violation_container_only() {
83        let yaml = r#"
84services:
85  web:
86    image: nginx
87    ports:
88      - 80
89"#;
90        // Container-only ports don't bind to host
91        assert!(check_yaml(yaml).is_empty());
92    }
93
94    #[test]
95    fn test_violation_unbound_port() {
96        let yaml = r#"
97services:
98  web:
99    image: nginx
100    ports:
101      - "8080:80"
102"#;
103        let failures = check_yaml(yaml);
104        assert_eq!(failures.len(), 1);
105        assert!(failures[0].message.contains("8080:80"));
106        assert!(failures[0].message.contains("127.0.0.1"));
107    }
108
109    #[test]
110    fn test_multiple_violations() {
111        let yaml = r#"
112services:
113  web:
114    image: nginx
115    ports:
116      - "8080:80"
117      - "127.0.0.1:8443:443"
118      - "3000:3000"
119"#;
120        let failures = check_yaml(yaml);
121        assert_eq!(failures.len(), 2); // 8080 and 3000, not 8443
122    }
123}