"""AYN Antivirus — Container Scanner. Scans Docker, Podman, and LXC containers for threats. Supports: listing containers, scanning container filesystems, inspecting container processes, checking container images, and detecting cryptominers/malware inside containers. """ from __future__ import annotations import json import logging import re import shutil import subprocess from dataclasses import dataclass, field from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from ayn_antivirus.constants import ( CRYPTO_MINER_PROCESS_NAMES, CRYPTO_POOL_DOMAINS, SUSPICIOUS_PORTS, ) from ayn_antivirus.scanners.base import BaseScanner from ayn_antivirus.utils.helpers import generate_id logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @dataclass class ContainerInfo: """Information about a discovered container.""" container_id: str name: str image: str status: str # running, stopped, paused runtime: str # docker, podman, lxc created: str ports: List[str] = field(default_factory=list) mounts: List[str] = field(default_factory=list) pid: int = 0 # host PID of container init process ip_address: str = "" labels: Dict[str, str] = field(default_factory=dict) def to_dict(self) -> dict: return { "container_id": self.container_id, "name": self.name, "image": self.image, "status": self.status, "runtime": self.runtime, "created": self.created, "ports": self.ports, "mounts": self.mounts, "pid": self.pid, "ip_address": self.ip_address, "labels": self.labels, } @dataclass class ContainerThreat: """A threat detected inside a container.""" container_id: str container_name: str runtime: str threat_name: str threat_type: str # virus, malware, miner, spyware, rootkit, misconfiguration severity: str # CRITICAL, HIGH, MEDIUM, LOW details: str file_path: str = "" process_name: str = "" timestamp: str = field( default_factory=lambda: datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), ) def to_dict(self) -> dict: return { "container_id": self.container_id, "container_name": self.container_name, "runtime": self.runtime, "threat_name": self.threat_name, "threat_type": self.threat_type, "severity": self.severity, "details": self.details, "file_path": self.file_path, "process_name": self.process_name, "timestamp": self.timestamp, } @dataclass class ContainerScanResult: """Result of scanning containers.""" scan_id: str start_time: str end_time: str = "" containers_found: int = 0 containers_scanned: int = 0 threats: List[ContainerThreat] = field(default_factory=list) containers: List[ContainerInfo] = field(default_factory=list) errors: List[str] = field(default_factory=list) @property def is_clean(self) -> bool: return len(self.threats) == 0 @property def duration_seconds(self) -> float: if not self.end_time or not self.start_time: return 0.0 try: s = datetime.strptime(self.start_time, "%Y-%m-%d %H:%M:%S") e = datetime.strptime(self.end_time, "%Y-%m-%d %H:%M:%S") return (e - s).total_seconds() except Exception: return 0.0 def to_dict(self) -> dict: return { "scan_id": self.scan_id, "start_time": self.start_time, "end_time": self.end_time, "containers_found": self.containers_found, "containers_scanned": self.containers_scanned, "threats_found": len(self.threats), "threats": [t.to_dict() for t in self.threats], "containers": [c.to_dict() for c in self.containers], "errors": self.errors, "duration_seconds": self.duration_seconds, } # --------------------------------------------------------------------------- # Scanner # --------------------------------------------------------------------------- class ContainerScanner(BaseScanner): """Scans Docker, Podman, and LXC containers for security threats. Gracefully degrades when a container runtime is not installed — only the available runtimes are exercised. """ _SAFE_CONTAINER_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$') def __init__(self) -> None: self._docker_cmd = self._find_command("docker") self._podman_cmd = self._find_command("podman") self._lxc_cmd = self._find_command("lxc-ls") self._incus_cmd = self._find_command("incus") self._available_runtimes: List[str] = [] if self._incus_cmd: self._available_runtimes.append("incus") if self._docker_cmd: self._available_runtimes.append("docker") if self._podman_cmd: self._available_runtimes.append("podman") if self._lxc_cmd: self._available_runtimes.append("lxc") # ------------------------------------------------------------------ # BaseScanner interface # ------------------------------------------------------------------ @property def name(self) -> str: return "container_scanner" @property def description(self) -> str: return ( "Scans Docker, Podman, and LXC containers for malware, " "miners, and misconfigurations" ) @property def available_runtimes(self) -> List[str]: return list(self._available_runtimes) def scan(self, target: Any = "all") -> ContainerScanResult: """Scan all containers or a specific one. Parameters ---------- target: ``"all"``, a runtime name (``"docker"``, ``"podman"``, ``"lxc"``), or a container ID / name. """ result = ContainerScanResult( scan_id=generate_id()[:16], start_time=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), ) if not self._available_runtimes: result.errors.append( "No container runtimes found (docker/podman/lxc not installed)" ) result.end_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") return result target = str(target) if target in ("all", "docker", "podman", "lxc"): runtime = "all" if target == "all" else target containers = self.list_containers( runtime=runtime, include_stopped=True, ) else: containers = self._find_container(target) result.containers = containers result.containers_found = len(containers) for container in containers: try: threats = self._scan_container(container) result.threats.extend(threats) result.containers_scanned += 1 except Exception as exc: msg = f"Error scanning {container.name}: {exc}" result.errors.append(msg) self._log_error("Error scanning container %s: %s", container.name, exc) result.end_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") return result def scan_container(self, container_id: str) -> ContainerScanResult: """Convenience — scan a single container by ID or name.""" cid = self._sanitize_id(container_id) return self.scan(target=cid) # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @staticmethod def _find_command(cmd: str) -> Optional[str]: return shutil.which(cmd) @staticmethod def _run_cmd( cmd: List[str], timeout: int = 30, ) -> Tuple[str, str, int]: """Run a shell command and return ``(stdout, stderr, returncode)``.""" try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, ) return proc.stdout.strip(), proc.stderr.strip(), proc.returncode except subprocess.TimeoutExpired: return "", f"Command timed out: {' '.join(cmd)}", -1 except FileNotFoundError: return "", f"Command not found: {cmd[0]}", -1 except Exception as exc: return "", str(exc), -1 def _sanitize_id(self, container_id: str) -> str: """Sanitize container ID/name for safe use in subprocess commands. Raises :class:`ValueError` if the ID contains invalid characters or exceeds length limits. """ cid = container_id.strip() if not cid or len(cid) > 128: raise ValueError(f"Invalid container ID length: {len(cid)}") if not self._SAFE_CONTAINER_ID.match(cid): raise ValueError(f"Invalid container ID characters: {cid!r}") return cid # ------------------------------------------------------------------ # Container discovery # ------------------------------------------------------------------ def list_containers( self, runtime: str = "all", include_stopped: bool = False, ) -> List[ContainerInfo]: """List all containers across available runtimes.""" containers: List[ContainerInfo] = [] runtimes = ( self._available_runtimes if runtime == "all" else [runtime] ) for rt in runtimes: if rt == "incus" and self._incus_cmd: containers.extend(self._list_incus(include_stopped)) elif rt == "docker" and self._docker_cmd: containers.extend(self._list_docker(include_stopped)) elif rt == "podman" and self._podman_cmd: containers.extend(self._list_podman(include_stopped)) elif rt == "lxc" and self._lxc_cmd: containers.extend(self._list_lxc()) return containers # -- Docker -------------------------------------------------------- def _list_docker(self, include_stopped: bool = False) -> List[ContainerInfo]: fmt = ( "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}" "\t{{.CreatedAt}}\t{{.Ports}}" ) cmd = [self._docker_cmd, "ps", "--format", fmt, "--no-trunc"] if include_stopped: cmd.append("-a") stdout, stderr, rc = self._run_cmd(cmd) if rc != 0: self._log_warning("Docker ps failed: %s", stderr) return [] containers: List[ContainerInfo] = [] for line in stdout.splitlines(): if not line.strip(): continue parts = line.split("\t") if len(parts) < 4: continue cid = parts[0][:12] name = parts[1] if len(parts) > 1 else "" image = parts[2] if len(parts) > 2 else "" status_str = parts[3] if len(parts) > 3 else "" created = parts[4] if len(parts) > 4 else "" ports_str = parts[5] if len(parts) > 5 else "" status = ( "running" if "Up" in status_str else "stopped" if "Exited" in status_str else "unknown" ) ports = ( [p.strip() for p in ports_str.split(",") if p.strip()] if ports_str else [] ) info = self._inspect_docker(cid) containers.append(ContainerInfo( container_id=cid, name=name, image=image, status=status, runtime="docker", created=created, ports=ports, mounts=info.get("mounts", []), pid=info.get("pid", 0), ip_address=info.get("ip", ""), labels=info.get("labels", {}), )) return containers def _inspect_docker(self, container_id: str) -> Dict[str, Any]: cid = self._sanitize_id(container_id) cmd = [self._docker_cmd, "inspect", cid] stdout, _, rc = self._run_cmd(cmd, timeout=10) if rc != 0: return {} try: data = json.loads(stdout) if not data: return {} c = data[0] state = c.get("State", {}) network = c.get("NetworkSettings", {}) mounts = [m.get("Source", "") for m in c.get("Mounts", [])] ip = "" for net_info in network.get("Networks", {}).values(): if net_info.get("IPAddress"): ip = net_info["IPAddress"] break return { "pid": state.get("Pid", 0), "ip": ip, "mounts": mounts, "labels": c.get("Config", {}).get("Labels", {}), } except (json.JSONDecodeError, KeyError, IndexError): return {} # -- Podman -------------------------------------------------------- def _list_podman(self, include_stopped: bool = False) -> List[ContainerInfo]: cmd = [self._podman_cmd, "ps", "--format", "json"] if include_stopped: cmd.append("-a") stdout, stderr, rc = self._run_cmd(cmd) if rc != 0: self._log_warning("Podman ps failed: %s", stderr) return [] try: data = json.loads(stdout) if stdout else [] except json.JSONDecodeError: return [] containers: List[ContainerInfo] = [] for c in data: cid = str(c.get("Id", ""))[:12] names = c.get("Names", []) name = names[0] if names else cid status_str = str(c.get("State", c.get("Status", ""))) status = ( "running" if status_str.lower() in ("running", "up") else "stopped" ) ports_list: List[str] = [] for p in c.get("Ports", []) or []: if isinstance(p, dict): ports_list.append( f"{p.get('hostPort', '')}:{p.get('containerPort', '')}" ) else: ports_list.append(str(p)) containers.append(ContainerInfo( container_id=cid, name=name, image=c.get("Image", ""), status=status, runtime="podman", created=str(c.get("Created", c.get("CreatedAt", ""))), ports=ports_list, pid=c.get("Pid", 0), labels=c.get("Labels", {}), )) return containers # -- LXC ----------------------------------------------------------- def _list_lxc(self) -> List[ContainerInfo]: stdout, stderr, rc = self._run_cmd( [self._lxc_cmd, "--fancy", "-F", "name,state,ipv4,pid"], ) if rc != 0: self._log_warning("LXC list failed: %s", stderr) return [] containers: List[ContainerInfo] = [] for line in stdout.splitlines()[1:]: # skip header parts = line.split() if len(parts) < 2: continue name = parts[0] state = parts[1].lower() ip = parts[2] if len(parts) > 2 and parts[2] != "-" else "" pid = ( int(parts[3]) if len(parts) > 3 and parts[3].isdigit() else 0 ) containers.append(ContainerInfo( container_id=name, name=name, image="lxc", status="running" if state == "running" else "stopped", runtime="lxc", created="", pid=pid, ip_address=ip, )) return containers # -- Incus --------------------------------------------------------- def _list_incus(self, include_stopped: bool = False) -> List[ContainerInfo]: """List Incus containers (and optionally VMs).""" cmd = [self._incus_cmd, "list", "--format", "json"] stdout, stderr, rc = self._run_cmd(cmd, timeout=15) if rc != 0: self._log_warning("Incus list failed: %s", stderr) return [] try: data = json.loads(stdout) if stdout else [] except json.JSONDecodeError: return [] containers: List[ContainerInfo] = [] for c in data: status_str = c.get("status", "").lower() if not include_stopped and status_str != "running": continue name = c.get("name", "") ctype = c.get("type", "container") # Extract IPv4 addresses from the network state. ip_address = "" net_state = c.get("state", {}).get("network", {}) or {} for iface_name, iface in net_state.items(): if iface_name == "lo": continue for addr in iface.get("addresses", []): if addr.get("family") == "inet" and addr.get("scope") == "global": ip_address = addr.get("address", "") break if ip_address: break # Fallback: try expanded_devices for static IP. if not ip_address: devices = c.get("expanded_devices", {}) for dev in devices.values(): if dev.get("type") == "nic" and dev.get("ipv4.address"): ip_address = dev["ipv4.address"] break config = c.get("config", {}) image_desc = config.get("image.description", "") image_os = config.get("image.os", "") image_release = config.get("image.release", "") image = image_desc or f"{image_os} {image_release}".strip() or ctype # Proxy ports (Incus proxy devices act like port mappings). ports: List[str] = [] for dev_name, dev in c.get("expanded_devices", {}).items(): if dev.get("type") == "proxy": listen = dev.get("listen", "") connect = dev.get("connect", "") if listen and connect: ports.append(f"{listen} -> {connect}") containers.append(ContainerInfo( container_id=name, name=name, image=image, status="running" if status_str == "running" else "stopped", runtime="incus", created=c.get("created_at", ""), ports=ports, ip_address=ip_address, labels={ k: v for k, v in config.items() if not k.startswith("volatile.") and not k.startswith("image.") }, )) return containers def _inspect_incus(self, container_name: str) -> Dict[str, Any]: """Inspect an Incus container for security-relevant config.""" name = self._sanitize_id(container_name) cmd = [self._incus_cmd, "config", "show", name] stdout, _, rc = self._run_cmd(cmd, timeout=10) if rc != 0: return {} try: import yaml data = yaml.safe_load(stdout) or {} except Exception: # Fallback: parse key lines manually. data = {} for line in stdout.splitlines(): line = line.strip() if ": " in line: k, v = line.split(": ", 1) data[k.strip()] = v.strip() return data # ------------------------------------------------------------------ # Container lookup # ------------------------------------------------------------------ def _find_container(self, identifier: str) -> List[ContainerInfo]: """Find a container by ID prefix or name across all runtimes.""" safe_id = self._sanitize_id(identifier) all_containers = self.list_containers( runtime="all", include_stopped=True, ) return [ c for c in all_containers if safe_id in c.container_id or safe_id.lower() == c.name.lower() ] # ------------------------------------------------------------------ # Scanning pipeline # ------------------------------------------------------------------ def _scan_container( self, container: ContainerInfo, ) -> List[ContainerThreat]: """Run all checks on a single container.""" threats: List[ContainerThreat] = [] # Running-only checks if container.status == "running": threats.extend(self._check_processes(container)) threats.extend(self._check_network(container)) # Always check these threats.extend(self._check_filesystem(container)) threats.extend(self._check_misconfigurations(container)) if container.runtime == "incus": threats.extend(self._check_incus_security(container)) else: threats.extend(self._check_image(container)) return threats # -- Process checks ------------------------------------------------ def _check_processes( self, container: ContainerInfo, ) -> List[ContainerThreat]: """Check running processes inside the container for miners / malware.""" threats: List[ContainerThreat] = [] cmd_prefix = self._get_exec_prefix(container) if not cmd_prefix: return threats # Try ``ps aux`` inside the container. stdout, _, rc = self._run_cmd(cmd_prefix + ["ps", "aux"], timeout=15) if rc != 0: # Fallback: ``docker|podman top``. if container.runtime in ("docker", "podman"): rt_cmd = ( self._docker_cmd if container.runtime == "docker" else self._podman_cmd ) stdout, _, rc = self._run_cmd( [rt_cmd, "top", container.container_id, "-eo", "pid,user,%cpu,%mem,comm,args"], timeout=15, ) if rc != 0: return threats miner_names_lower = {n.lower() for n in CRYPTO_MINER_PROCESS_NAMES} for line in stdout.splitlines()[1:]: # skip header parts = line.split() if len(parts) < 6: continue process_name = parts[-1].split("/")[-1].lower() full_cmd = ( " ".join(parts[10:]) if len(parts) > 10 else " ".join(parts[5:]) ) # Known miner process names for miner in miner_names_lower: if miner in process_name or miner in full_cmd.lower(): threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name=f"CryptoMiner.Container.{miner.title()}", threat_type="miner", severity="CRITICAL", details=( f"Crypto miner process '{process_name}' detected " f"inside container. CMD: {full_cmd[:200]}" ), process_name=process_name, )) break # High CPU usage try: cpu = float(parts[2]) if cpu > 80: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="HighCPU.Container.SuspiciousProcess", threat_type="miner", severity="HIGH", details=( f"Process '{process_name}' using {cpu}% CPU " f"inside container. Possible cryptominer." ), process_name=process_name, )) except (ValueError, IndexError): pass # Reverse shells _SHELL_INDICATORS = [ "nc -e", "ncat -e", "bash -i", "/dev/tcp/", "python -c 'import socket", "perl -e 'use Socket", "ruby -rsocket", "php -r '$sock", ] for indicator in _SHELL_INDICATORS: if indicator in full_cmd: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="ReverseShell.Container", threat_type="malware", severity="CRITICAL", details=( f"Reverse shell detected inside container: " f"{full_cmd[:200]}" ), process_name=process_name, )) break return threats # -- Network checks ------------------------------------------------ def _check_network( self, container: ContainerInfo, ) -> List[ContainerThreat]: """Check container network connections for suspicious activity.""" threats: List[ContainerThreat] = [] cmd_prefix = self._get_exec_prefix(container) if not cmd_prefix: return threats stdout, _, rc = self._run_cmd( cmd_prefix + ["sh", "-c", "ss -tnp 2>/dev/null || netstat -tnp 2>/dev/null"], timeout=15, ) if rc != 0 or not stdout: return threats pool_domains_lower = {d.lower() for d in CRYPTO_POOL_DOMAINS} for line in stdout.splitlines(): line_lower = line.lower() for pool in pool_domains_lower: if pool in line_lower: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name=f"MiningPool.Container.{pool}", threat_type="miner", severity="CRITICAL", details=( f"Container connecting to mining pool: " f"{line.strip()[:200]}" ), )) for port in SUSPICIOUS_PORTS: if f":{port}" in line: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name=f"SuspiciousPort.Container.{port}", threat_type="malware", severity="MEDIUM", details=( f"Container connection on suspicious port {port}: " f"{line.strip()[:200]}" ), )) return threats # -- Filesystem checks --------------------------------------------- def _check_filesystem( self, container: ContainerInfo, ) -> List[ContainerThreat]: """Scan container filesystem for suspicious files.""" threats: List[ContainerThreat] = [] if container.runtime == "incus": return self._check_filesystem_via_exec(container) if container.runtime not in ("docker", "podman"): return threats runtime_cmd = ( self._docker_cmd if container.runtime == "docker" else self._podman_cmd ) if not runtime_cmd: return threats # -- suspicious scripts in temp directories -------------------- check_dirs = ["/tmp", "/var/tmp", "/dev/shm", "/root", "/home"] for check_dir in check_dirs: cmd = [ runtime_cmd, "exec", container.container_id, "find", check_dir, "-maxdepth", "3", "-type", "f", "-name", "*.sh", "-o", "-name", "*.py", "-o", "-name", "*.elf", "-o", "-name", "*.bin", ] stdout, _, rc = self._run_cmd(cmd, timeout=15) if rc != 0 or not stdout: continue for fpath in stdout.splitlines()[:50]: fpath = fpath.strip() if not fpath: continue cat_cmd = [ runtime_cmd, "exec", container.container_id, "head", "-c", "8192", fpath, ] content, _, crc = self._run_cmd(cat_cmd, timeout=10) if crc != 0: continue ct_lower = content.lower() if "stratum+tcp://" in ct_lower or "stratum+ssl://" in ct_lower: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="MinerConfig.Container", threat_type="miner", severity="CRITICAL", details=( f"Mining configuration found in container: {fpath}" ), file_path=fpath, )) _MALICIOUS_PATTERNS = [ "eval(base64_decode(", "exec(base64.b64decode(", "import socket;socket.socket", "/dev/tcp/", "bash -i >& /dev/tcp/", ] if any(s in ct_lower for s in _MALICIOUS_PATTERNS): threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="MaliciousScript.Container", threat_type="malware", severity="HIGH", details=( f"Suspicious script pattern in container file: " f"{fpath}" ), file_path=fpath, )) # -- unexpected SUID binaries ---------------------------------- _EXPECTED_SUID = { "/usr/bin/passwd", "/usr/bin/su", "/usr/bin/sudo", "/usr/bin/newgrp", "/usr/bin/chfn", "/usr/bin/chsh", "/usr/bin/gpasswd", "/bin/su", "/bin/mount", "/bin/umount", "/usr/bin/mount", "/usr/bin/umount", } cmd = [ runtime_cmd, "exec", container.container_id, "find", "/", "-maxdepth", "4", "-perm", "-4000", "-type", "f", ] stdout, _, rc = self._run_cmd(cmd, timeout=20) if rc == 0 and stdout: for fpath in stdout.splitlines(): fpath = fpath.strip() if fpath and fpath not in _EXPECTED_SUID: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="UnexpectedSUID.Container", threat_type="rootkit", severity="MEDIUM", details=( f"Unexpected SUID binary in container: {fpath}" ), file_path=fpath, )) return threats # -- Misconfiguration checks --------------------------------------- def _check_misconfigurations( self, container: ContainerInfo, ) -> List[ContainerThreat]: """Check container for security misconfigurations.""" threats: List[ContainerThreat] = [] # Incus misconfigs are handled in _check_incus_security. if container.runtime not in ("docker", "podman"): return threats runtime_cmd = ( self._docker_cmd if container.runtime == "docker" else self._podman_cmd ) if not runtime_cmd: return threats stdout, _, rc = self._run_cmd( [runtime_cmd, "inspect", container.container_id], ) if rc != 0: return threats try: data = json.loads(stdout) if not data: return threats c = data[0] except (json.JSONDecodeError, IndexError): return threats host_config = c.get("HostConfig", {}) config = c.get("Config", {}) # Running as root user = config.get("User", "") if not user or user in ("root", "0"): threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="RunAsRoot.Container", threat_type="misconfiguration", severity="MEDIUM", details=( "Container running as root user. " "Use a non-root user for better isolation." ), )) # Privileged mode if host_config.get("Privileged", False): threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="PrivilegedMode.Container", threat_type="misconfiguration", severity="CRITICAL", details=( "Container running in privileged mode! " "This grants full host access." ), )) # Host network if host_config.get("NetworkMode", "") == "host": threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="HostNetwork.Container", threat_type="misconfiguration", severity="HIGH", details=( "Container using host network mode. " "This bypasses network isolation." ), )) # Host PID namespace if host_config.get("PidMode") == "host": threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="HostPID.Container", threat_type="misconfiguration", severity="HIGH", details=( "Container sharing host PID namespace. " "Container can see all host processes." ), )) # Dangerous capabilities _DANGEROUS_CAPS = { "SYS_ADMIN", "SYS_PTRACE", "NET_ADMIN", "SYS_RAWIO", "DAC_OVERRIDE", "SYS_MODULE", "NET_RAW", } added_caps = set(host_config.get("CapAdd", []) or []) for cap in sorted(added_caps & _DANGEROUS_CAPS): threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name=f"DangerousCap.Container.{cap}", threat_type="misconfiguration", severity="HIGH", details=f"Container has dangerous capability: {cap}", )) # Sensitive host mounts _SENSITIVE_MOUNTS = { "/", "/etc", "/var/run/docker.sock", "/proc", "/sys", "/dev", "/root", "/home", } for mount in c.get("Mounts", []): src = mount.get("Source", "") if src in _SENSITIVE_MOUNTS: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="SensitiveMount.Container", threat_type="misconfiguration", severity="HIGH", details=( f"Container mounts sensitive host path: " f"{src} -> {mount.get('Destination', '')}" ), )) # No resource limits mem_limit = host_config.get("Memory", 0) cpu_quota = host_config.get("CpuQuota", 0) if mem_limit == 0 and cpu_quota == 0: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="NoResourceLimits.Container", threat_type="misconfiguration", severity="LOW", details=( "Container has no memory or CPU limits. " "A compromised container could consume all host resources." ), )) # Security profiles disabled security_opt = host_config.get("SecurityOpt", []) or [] for opt in security_opt: opt_str = str(opt) if "apparmor=unconfined" in opt_str or "seccomp=unconfined" in opt_str: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="SecurityDisabled.Container", threat_type="misconfiguration", severity="HIGH", details=f"Container security profile disabled: {opt}", )) return threats # -- Image checks -------------------------------------------------- @staticmethod def _check_image( container: ContainerInfo, ) -> List[ContainerThreat]: """Check if the container image has known issues.""" threats: List[ContainerThreat] = [] # Using :latest or untagged image image_name = container.image.split("/")[-1] if container.image.endswith(":latest") or ":" not in image_name: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime=container.runtime, threat_name="LatestTag.Container", threat_type="misconfiguration", severity="LOW", details=( f"Container using ':latest' or untagged image " f"'{container.image}'. " f"Pin to a specific version for reproducibility." ), )) return threats # -- Incus security checks ----------------------------------------- def _check_incus_security( self, container: ContainerInfo, ) -> List[ContainerThreat]: """Check Incus container security configuration.""" threats: List[ContainerThreat] = [] labels = container.labels or {} # security.privileged = true if labels.get("security.privileged") == "true": threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime="incus", threat_name="PrivilegedMode.Incus", threat_type="misconfiguration", severity="CRITICAL", details=( "Incus container running in privileged mode! " "This grants full host access." ), )) # security.nesting=true is required for Docker-in-Incus setups # (e.g. Dokploy). Only flag it when combined with privileged mode. if ( labels.get("security.nesting") == "true" and labels.get("security.privileged") == "true" ): threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime="incus", threat_name="PrivilegedNesting.Incus", threat_type="misconfiguration", severity="CRITICAL", details=( "Container has security.nesting=true AND " "security.privileged=true. This is a dangerous " "combination allowing full host escape." ), )) # Check for Docker inside Incus (nested Docker). if container.status == "running" and self._incus_cmd: name = self._sanitize_id(container.name) stdout, _, rc = self._run_cmd( [self._incus_cmd, "exec", name, "--", "docker", "ps", "-q"], timeout=10, ) if rc == 0 and stdout.strip(): n_docker = len(stdout.strip().splitlines()) # Not a threat — informational label stored in labels # for the dashboard to display. container.labels["_nested_docker_containers"] = str(n_docker) return threats def _check_filesystem_via_exec( self, container: ContainerInfo, ) -> List[ContainerThreat]: """Scan an Incus container's filesystem via ``incus exec``.""" threats: List[ContainerThreat] = [] if container.status != "running" or not self._incus_cmd: return threats name = self._sanitize_id(container.name) check_dirs = ["/tmp", "/var/tmp", "/dev/shm", "/root"] for check_dir in check_dirs: cmd = [ self._incus_cmd, "exec", name, "--", "find", check_dir, "-maxdepth", "3", "-type", "f", "-name", "*.sh", "-o", "-name", "*.py", "-o", "-name", "*.elf", "-o", "-name", "*.bin", ] stdout, _, rc = self._run_cmd(cmd, timeout=15) if rc != 0 or not stdout: continue for fpath in stdout.splitlines()[:50]: fpath = fpath.strip() if not fpath: continue cat_cmd = [ self._incus_cmd, "exec", name, "--", "head", "-c", "8192", fpath, ] content, _, crc = self._run_cmd(cat_cmd, timeout=10) if crc != 0: continue ct_lower = content.lower() if "stratum+tcp://" in ct_lower or "stratum+ssl://" in ct_lower: threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime="incus", threat_name="MinerConfig.Incus", threat_type="miner", severity="CRITICAL", details=f"Mining config found in container: {fpath}", file_path=fpath, )) _MALICIOUS_PATTERNS = [ "eval(base64_decode(", "exec(base64.b64decode(", "import socket;socket.socket", "/dev/tcp/", "bash -i >& /dev/tcp/", ] if any(s in ct_lower for s in _MALICIOUS_PATTERNS): threats.append(ContainerThreat( container_id=container.container_id, container_name=container.name, runtime="incus", threat_name="MaliciousScript.Incus", threat_type="malware", severity="HIGH", details=f"Suspicious script in container file: {fpath}", file_path=fpath, )) return threats # ------------------------------------------------------------------ # Exec prefix # ------------------------------------------------------------------ def _get_exec_prefix( self, container: ContainerInfo, ) -> Optional[List[str]]: """Get the command prefix to execute commands inside a container.""" if container.status != "running": return None cid = self._sanitize_id(container.container_id) if container.runtime == "incus" and self._incus_cmd: name = self._sanitize_id(container.name) return [self._incus_cmd, "exec", name, "--"] if container.runtime == "docker" and self._docker_cmd: return [self._docker_cmd, "exec", cid] if container.runtime == "podman" and self._podman_cmd: return [self._podman_cmd, "exec", cid] if container.runtime == "lxc": lxc_attach = shutil.which("lxc-attach") if lxc_attach: name = self._sanitize_id(container.name) return [lxc_attach, "-n", name, "--"] return None # ------------------------------------------------------------------ # Utility methods # ------------------------------------------------------------------ def get_container_logs( self, container_id: str, runtime: str = "docker", lines: int = 100, ) -> str: """Get recent logs from a container.""" cid = self._sanitize_id(container_id) if runtime == "incus" and self._incus_cmd: stdout, _, rc = self._run_cmd( [self._incus_cmd, "exec", cid, "--", "journalctl", "--no-pager", "-n", str(lines)], timeout=15, ) return stdout if rc == 0 else "" if runtime in ("docker", "podman"): cmd_bin = ( self._docker_cmd if runtime == "docker" else self._podman_cmd ) if not cmd_bin: return "" stdout, _, rc = self._run_cmd( [cmd_bin, "logs", "--tail", str(lines), cid], timeout=15, ) return stdout if rc == 0 else "" return "" def get_container_stats( self, container_id: str, runtime: str = "docker", ) -> Dict[str, Any]: """Get resource usage stats for a container.""" cid = self._sanitize_id(container_id) if runtime in ("docker", "podman"): cmd_bin = ( self._docker_cmd if runtime == "docker" else self._podman_cmd ) if not cmd_bin: return {} fmt = ( '{"cpu":"{{.CPUPerc}}","mem":"{{.MemUsage}}",' '"net":"{{.NetIO}}","block":"{{.BlockIO}}",' '"pids":"{{.PIDs}}"}' ) stdout, _, rc = self._run_cmd( [cmd_bin, "stats", cid, "--no-stream", "--format", fmt], timeout=15, ) if rc != 0: return {} try: return json.loads(stdout) except json.JSONDecodeError: return {} return {}