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