cc_audit/engine/scanners/
dockerfile.rs1use 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 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 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 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 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}