Skip to main content

cc_audit/scanner/
dockerfile.rs

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