Skip to main content

cc_audit/engine/scanners/
dockerfile.rs

1use crate::engine::scanner::{Scanner, ScannerConfig};
2use crate::error::Result;
3use crate::ignore::IgnoreFilter;
4use crate::rules::Finding;
5use std::path::Path;
6use walkdir::WalkDir;
7
8pub struct DockerScanner {
9    config: ScannerConfig,
10}
11
12impl_scanner_builder!(DockerScanner);
13impl_content_scanner!(DockerScanner);
14
15impl DockerScanner {
16    pub fn with_ignore_filter(mut self, filter: IgnoreFilter) -> Self {
17        self.config = self.config.with_ignore_filter(filter);
18        self
19    }
20
21    fn is_dockerfile(path: &Path) -> bool {
22        let file_name = path
23            .file_name()
24            .and_then(|n| n.to_str())
25            .map(|n| n.to_lowercase());
26
27        match file_name {
28            Some(name) => {
29                name == "dockerfile"
30                    || name.ends_with(".dockerfile")
31                    || name == "docker-compose.yml"
32                    || name == "docker-compose.yaml"
33                    || name == "compose.yml"
34                    || name == "compose.yaml"
35            }
36            None => false,
37        }
38    }
39}
40
41impl Scanner for DockerScanner {
42    fn scan_file(&self, path: &Path) -> Result<Vec<Finding>> {
43        let content = self.config.read_file(path)?;
44        let path_str = path.display().to_string();
45        Ok(self.config.check_content(&content, &path_str))
46    }
47
48    fn scan_directory(&self, dir: &Path) -> Result<Vec<Finding>> {
49        let mut findings = Vec::new();
50
51        // Check for common Dockerfile locations
52        let dockerfile_names = [
53            "Dockerfile",
54            "dockerfile",
55            "Dockerfile.dev",
56            "Dockerfile.prod",
57            "docker-compose.yml",
58            "docker-compose.yaml",
59            "compose.yml",
60            "compose.yaml",
61        ];
62
63        for name in &dockerfile_names {
64            let docker_file = dir.join(name);
65            if docker_file.exists()
66                && !self.config.is_ignored(&docker_file)
67                && let Ok(file_findings) = self.scan_file(&docker_file)
68            {
69                findings.extend(file_findings);
70            }
71        }
72
73        // Scan for any .dockerfile files in the directory
74        for entry in WalkDir::new(dir)
75            .max_depth(3)
76            .into_iter()
77            .filter_map(|e| e.ok())
78        {
79            let path = entry.path();
80            if path.is_file() && Self::is_dockerfile(path) && !self.config.is_ignored(path) {
81                // Avoid scanning the same file twice
82                let is_common_name = dockerfile_names.iter().any(|n| {
83                    path.file_name()
84                        .and_then(|f| f.to_str())
85                        .is_some_and(|f| f == *n)
86                });
87
88                if (!is_common_name || path.parent() != Some(dir))
89                    && let Ok(file_findings) = self.scan_file(path)
90                {
91                    findings.extend(file_findings);
92                }
93            }
94        }
95
96        Ok(findings)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::engine::scanner::ContentScanner;
104    use std::fs;
105    use tempfile::TempDir;
106
107    fn create_dockerfile(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
108        let path = dir.path().join(name);
109        fs::write(&path, content).unwrap();
110        path
111    }
112
113    #[test]
114    fn test_scan_clean_dockerfile() {
115        let dir = TempDir::new().unwrap();
116        create_dockerfile(
117            &dir,
118            "Dockerfile",
119            r#"
120FROM node:18-alpine
121WORKDIR /app
122USER node
123COPY . .
124RUN npm install
125CMD ["node", "index.js"]
126"#,
127        );
128
129        let scanner = DockerScanner::new();
130        let findings = scanner.scan_path(dir.path()).unwrap();
131
132        // Should have no critical findings
133        let critical: Vec<_> = findings
134            .iter()
135            .filter(|f| f.severity >= crate::rules::Severity::High)
136            .collect();
137        assert!(
138            critical.is_empty(),
139            "Clean Dockerfile should have no high/critical findings"
140        );
141    }
142
143    #[test]
144    fn test_detect_privileged_mode() {
145        let dir = TempDir::new().unwrap();
146        create_dockerfile(
147            &dir,
148            "docker-compose.yml",
149            r#"
150services:
151  app:
152    image: nginx
153    privileged: true
154"#,
155        );
156
157        let scanner = DockerScanner::new();
158        let findings = scanner.scan_path(dir.path()).unwrap();
159
160        assert!(
161            findings.iter().any(|f| f.id == "DK-001"),
162            "Should detect privileged: true"
163        );
164    }
165
166    #[test]
167    fn test_detect_root_user() {
168        let dir = TempDir::new().unwrap();
169        create_dockerfile(
170            &dir,
171            "Dockerfile",
172            r#"
173FROM ubuntu:22.04
174USER root
175RUN apt-get update
176"#,
177        );
178
179        let scanner = DockerScanner::new();
180        let findings = scanner.scan_path(dir.path()).unwrap();
181
182        assert!(
183            findings.iter().any(|f| f.id == "DK-002"),
184            "Should detect USER root"
185        );
186    }
187
188    #[test]
189    fn test_detect_curl_pipe_bash_in_run() {
190        let dir = TempDir::new().unwrap();
191        create_dockerfile(
192            &dir,
193            "Dockerfile",
194            r#"
195FROM ubuntu:22.04
196RUN curl -fsSL https://get.docker.com | bash
197"#,
198        );
199
200        let scanner = DockerScanner::new();
201        let findings = scanner.scan_path(dir.path()).unwrap();
202
203        assert!(
204            findings.iter().any(|f| f.id == "DK-003"),
205            "Should detect curl | bash in RUN"
206        );
207    }
208
209    #[test]
210    fn test_scan_compose_yaml() {
211        let dir = TempDir::new().unwrap();
212        create_dockerfile(
213            &dir,
214            "compose.yaml",
215            r#"
216services:
217  db:
218    image: postgres
219    cap_add:
220      - SYS_ADMIN
221"#,
222        );
223
224        let scanner = DockerScanner::new();
225        let findings = scanner.scan_path(dir.path()).unwrap();
226
227        assert!(
228            findings.iter().any(|f| f.id == "DK-001"),
229            "Should detect SYS_ADMIN capability"
230        );
231    }
232
233    #[test]
234    fn test_scan_custom_dockerfile() {
235        let dir = TempDir::new().unwrap();
236        create_dockerfile(
237            &dir,
238            "app.dockerfile",
239            r#"
240FROM node:18
241USER root
242RUN npm install
243"#,
244        );
245
246        let scanner = DockerScanner::new();
247        let findings = scanner.scan_path(dir.path()).unwrap();
248
249        assert!(
250            findings.iter().any(|f| f.id == "DK-002"),
251            "Should detect USER root in custom.dockerfile"
252        );
253    }
254
255    #[test]
256    fn test_scan_empty_directory() {
257        let dir = TempDir::new().unwrap();
258        let scanner = DockerScanner::new();
259        let findings = scanner.scan_path(dir.path()).unwrap();
260        assert!(findings.is_empty());
261    }
262
263    #[test]
264    fn test_scan_nonexistent_path() {
265        let scanner = DockerScanner::new();
266        let result = scanner.scan_path(Path::new("/nonexistent/path"));
267        assert!(result.is_err());
268    }
269
270    #[test]
271    fn test_scan_file_directly() {
272        let dir = TempDir::new().unwrap();
273        let path = create_dockerfile(
274            &dir,
275            "Dockerfile",
276            r#"
277FROM ubuntu:22.04
278RUN docker run --privileged nginx
279"#,
280        );
281
282        let scanner = DockerScanner::new();
283        let findings = scanner.scan_file(&path).unwrap();
284
285        assert!(findings.iter().any(|f| f.id == "DK-001"));
286    }
287
288    #[test]
289    fn test_default_trait() {
290        let scanner = DockerScanner::default();
291        let dir = TempDir::new().unwrap();
292        let findings = scanner.scan_path(dir.path()).unwrap();
293        assert!(findings.is_empty());
294    }
295
296    #[test]
297    fn test_scan_content_directly() {
298        let scanner = DockerScanner::new();
299        let findings = scanner
300            .scan_content("privileged: true", "compose.yml")
301            .unwrap();
302        assert!(findings.iter().any(|f| f.id == "DK-001"));
303    }
304
305    #[test]
306    fn test_is_dockerfile() {
307        assert!(DockerScanner::is_dockerfile(Path::new("Dockerfile")));
308        assert!(DockerScanner::is_dockerfile(Path::new("dockerfile")));
309        assert!(DockerScanner::is_dockerfile(Path::new("app.dockerfile")));
310        assert!(DockerScanner::is_dockerfile(Path::new(
311            "docker-compose.yml"
312        )));
313        assert!(DockerScanner::is_dockerfile(Path::new(
314            "docker-compose.yaml"
315        )));
316        assert!(DockerScanner::is_dockerfile(Path::new("compose.yml")));
317        assert!(DockerScanner::is_dockerfile(Path::new("compose.yaml")));
318        assert!(!DockerScanner::is_dockerfile(Path::new("README.md")));
319        assert!(!DockerScanner::is_dockerfile(Path::new("script.sh")));
320    }
321
322    #[test]
323    fn test_scan_file_read_error() {
324        let dir = TempDir::new().unwrap();
325        let scanner = DockerScanner::new();
326        let result = scanner.scan_file(dir.path());
327        assert!(result.is_err());
328    }
329
330    #[test]
331    fn test_scan_nested_dockerfile() {
332        let dir = TempDir::new().unwrap();
333        let subdir = dir.path().join("services").join("api");
334        fs::create_dir_all(&subdir).unwrap();
335        let path = subdir.join("Dockerfile");
336        fs::write(&path, "FROM ubuntu\nUSER root").unwrap();
337
338        let scanner = DockerScanner::new();
339        let findings = scanner.scan_path(dir.path()).unwrap();
340
341        assert!(
342            findings.iter().any(|f| f.id == "DK-002"),
343            "Should detect USER root in nested Dockerfile"
344        );
345    }
346
347    #[test]
348    fn test_detect_cap_add_all() {
349        let dir = TempDir::new().unwrap();
350        create_dockerfile(
351            &dir,
352            "docker-compose.yml",
353            r#"
354services:
355  app:
356    image: nginx
357    cap_add:
358      - ALL
359"#,
360        );
361
362        let scanner = DockerScanner::new();
363        let findings = scanner.scan_path(dir.path()).unwrap();
364
365        assert!(
366            findings.iter().any(|f| f.id == "DK-001"),
367            "Should detect cap_add: ALL"
368        );
369    }
370
371    #[cfg(unix)]
372    #[test]
373    fn test_scan_path_not_file_or_directory() {
374        use std::process::Command;
375
376        let dir = TempDir::new().unwrap();
377        let fifo_path = dir.path().join("test_fifo");
378
379        let status = Command::new("mkfifo")
380            .arg(&fifo_path)
381            .status()
382            .expect("Failed to create FIFO");
383
384        if status.success() && fifo_path.exists() {
385            let scanner = DockerScanner::new();
386            let result = scanner.scan_path(&fifo_path);
387            assert!(result.is_err());
388        }
389    }
390}