Skip to main content

cc_audit/scanner/
dockerfile.rs

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