1286 lines
48 KiB
Python
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 {}
|