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.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 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 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 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 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 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 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}