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