Files
calvana/ayn-antivirus/ayn_antivirus/scanners/container_scanner.py

1286 lines
48 KiB
Python

"""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 {}