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