Güvenli Konteyner: Saldırı Yüzeyini Azaltma Yöntemleri

Konteyner teknolojisi hayatımızı kolaylaştırdı, bunu kimse inkar edemez. Ama “docker run” yazıp geçtiğimizde aslında ne kadar büyük bir saldırı yüzeyi açtığımızı çoğumuz fark etmiyoruz. Geçen ay bir müşterimde yaptığım güvenlik denetiminde, production ortamında root olarak çalışan, tüm host portlarına erişimi olan ve üç yıldır güncellenmemiş base image kullanan konteynerler buldum. Bu yazıda o tür felaketleri yaşamamanız için Docker konteynerlerinde saldırı yüzeyini nasıl minimize edeceğinizi, gerçek dünya senaryolarıyla anlatacağım.

Neden Konteyner Güvenliği Bu Kadar Önemli?

Docker’ın popülerleşmesiyle birlikte “konteynerlar izole değil mi zaten?” düşüncesi yaygınlaştı. Evet, kısmen izole. Ama bu izolasyon sizin düşündüğünüz kadar sağlam bir duvar değil. Konteyner, host kernel’ini paylaşıyor. Yani çekirdek seviyesinde bir açık, tüm konteynerleri ve host sistemi tehlikeye atabilir.

2019’daki runc açığını hatırlarsınız, CVE-2019-5736. Kötü niyetli bir konteyner, host üzerindeki runc binary’sini üzerine yazabiliyordu. Bu tek bir açık mıydı? Evet. Ama milyonlarca production sistemi etkiledi. Demek ki “konteyner içindeyiz, izoliz” diye rahatlamak doğru değil.

Saldırı yüzeyini azaltmak ise birkaç temel prensibe dayanıyor:

  • Çalışan her servis potansiyel bir giriş noktasıdır
  • Root yetkisi her zaman tehlikelidir
  • Gereksiz araçlar ve kütüphaneler ek risk demektir
  • Ağ erişimi ne kadar geniş olursa saldırı yüzeyi o kadar büyür

Minimal Base Image Kullanımı

En yaygın hatalardan biri ubuntu:latest ya da debian:latest gibi şişirilmiş base image’larla başlamak. Bu image’lar içinde curl, wget, bash, netcat ve onlarca başka araç geliyor. Bunların büyük çoğunluğuna uygulamanızın çalışması için ihtiyaç yok. Ama bir saldırgan konteynere girdiğinde bu araçların hepsini kullanabilir.

Distroless Image’lar

Google’ın distroless projesini kesinlikle incelemenizi öneririm. Bu image’larda shell bile yok. Sadece uygulamanızın çalışması için gereken minimum bileşenler mevcut.

# Kötü örnek: Gereksiz yere büyük image
FROM ubuntu:22.04
RUN apt-get install -y python3 python3-pip
COPY app.py .
CMD ["python3", "app.py"]

# İyi örnek: Distroless kullanımı
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .

FROM gcr.io/distroless/python3-debian11
WORKDIR /app
COPY --from=builder /app /app
CMD ["app.py"]

Distroless ile image boyutunuz dramatik biçimde küçülür ve içeride shell olmadığı için bir saldırgan konteynere girse bile komut çalıştırmak için ciddi zorlukla karşılaşır.

Alpine Linux Tercih Edin

Eğer distroless uygulamanız için çok kısıtlayıcıysa, Alpine Linux iyi bir ara seçenek. Tam Ubuntu’nun yanında devasa küçük, musl libc kullanıyor ve minimal paket sayısıyla geliyor.

# Node.js uygulaması için Alpine tabanlı multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:18-alpine
# Gereksiz paketleri kaldır
RUN apk del --no-cache curl wget
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/
# Root olmayan kullanıcı oluştur
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]

Root Olmayan Kullanıcıyla Çalıştırma

Bu muhtemelen en kritik nokta. Varsayılan olarak Docker konteynerleri root kullanıcısıyla çalışır. Konteyner escape açığı durumunda, root olarak çalışan bir konteyner host üzerinde de root yetkisi kazandırabilir.

# Dockerfile içinde non-root kullanıcı oluşturma
FROM python:3.11-slim

WORKDIR /app

# Önce bağımlılıkları kur (root gerekebilir)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Uygulama dosyalarını kopyala
COPY . .

# Dedicated kullanıcı oluştur
RUN groupadd --gid 1001 appgroup && 
    useradd --uid 1001 --gid appgroup --shell /bin/sh --create-home appuser

# Dosya sahipliğini ver
RUN chown -R appuser:appgroup /app

# Kullanıcıyı değiştir
USER appuser

CMD ["python", "app.py"]

Mevcut konteynerinizin hangi kullanıcıyla çalıştığını kontrol etmek için:

# Çalışan konteynerde kullanıcıyı kontrol et
docker exec konteyner_adi id

# Tüm çalışan konteynerlerin kullanıcı bilgisini gör
docker ps -q | xargs -I{} docker inspect {} --format='{{.Name}}: {{.Config.User}}'

Boş çıktı alırsanız ve User alanı boşsa, o konteyner root olarak çalışıyor demektir. Acil müdahale edin.

Linux Capabilities Kısıtlaması

Linux capabilities sistemi, root yetkilerini parçalara böler. Normalde root kullanıcısı onlarca farklı ayrıcalığa sahiptir. Ağ arayüzü değiştirme, sistem saatini ayarlama, kernel modülü yükleme… Konteynerlere bunların tamamını vermek yerine sadece ihtiyaç duydukları yetenekleri verin.

# Tüm capabilities'leri kaldır, sadece gerekenleri ekle
docker run 
  --cap-drop=ALL 
  --cap-add=NET_BIND_SERVICE 
  --name web-server 
  nginx:alpine

# Docker Compose ile capabilities yönetimi
# docker-compose.yml
version: "3.8"
services:
  web:
    image: nginx:alpine
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
      - CHOWN
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run

Hangi capabilities’lere ihtiyacınız olduğunu anlamak için önce konteyneri normal şekilde çalıştırıp sonra docker inspect ile analiz edebilirsiniz. Ya da amicontained aracını kullanabilirsiniz.

# amicontained ile mevcut capabilities'leri görüntüle
docker run --rm -it r.j3ss.co/amicontained

# Belirli bir konteynerdeki capabilities'leri listele
docker inspect konteyner_adi | grep -A 20 "CapAdd"

Read-Only Filesystem

Bir saldırgan konteynere girdiğinde genellikle persistence sağlamak ister. Bunun en yaygın yolu dosya sistemine bir şeyler yazmaktır. Read-only filesystem ile bunu engelliyoruz.

# Konteyneri read-only başlat
docker run --read-only 
  --tmpfs /tmp 
  --tmpfs /var/run 
  --name myapp 
  myapp:latest

# Kubernetes pod spec'inde read-only
# securityContext:
#   readOnlyRootFilesystem: true

Tabi bazı uygulamalar geçici dosya yazmak zorunda. Bunun için tmpfs mount kullanıyoruz. tmpfs RAM’e yazıyor, disk’e değil. Ve konteyner durduğunda otomatik temizleniyor.

Bir PostgreSQL konteyneri için pratik örnek:

docker run -d 
  --name postgres-secure 
  --read-only 
  --tmpfs /tmp 
  --tmpfs /var/run/postgresql 
  -v postgres-data:/var/lib/postgresql/data 
  -e POSTGRES_PASSWORD=gizlisifre 
  --cap-drop=ALL 
  --cap-add=SETUID 
  --cap-add=SETGID 
  --cap-add=DAC_READ_SEARCH 
  --security-opt=no-new-privileges:true 
  postgres:15-alpine

Seccomp ve AppArmor Profilleri

Capabilities kısıtlamasının ötesine geçmek istiyorsanız seccomp profilleri kullanın. Seccomp, konteynerin yapabileceği sistem çağrılarını (syscall) kısıtlar. Bir web uygulamasının mount() veya reboot() sistem çağrısına neden ihtiyacı olsun ki?

# Özel seccomp profili oluştur
cat > /etc/docker/seccomp/myapp-profile.json << 'EOF'
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": [
        "read", "write", "open", "close", "stat", "fstat",
        "poll", "lseek", "mmap", "mprotect", "munmap", "brk",
        "rt_sigaction", "rt_sigprocmask", "ioctl", "access",
        "pipe", "select", "sched_yield", "mremap", "msync",
        "mincore", "madvise", "dup", "dup2", "nanosleep",
        "getitimer", "alarm", "setitimer", "getpid", "sendfile",
        "socket", "connect", "accept", "sendto", "recvfrom",
        "sendmsg", "recvmsg", "shutdown", "bind", "listen",
        "getsockname", "getpeername", "socketpair", "setsockopt",
        "getsockopt", "clone", "fork", "vfork", "execve",
        "exit", "wait4", "kill", "uname", "fcntl", "flock",
        "fsync", "fdatasync", "truncate", "ftruncate", "getcwd",
        "chdir", "rename", "mkdir", "rmdir", "creat", "link",
        "unlink", "readlink", "chmod", "fchmod", "chown",
        "fchown", "lchown", "umask", "gettimeofday", "getrlimit",
        "getrusage", "sysinfo", "times", "getuid", "syslog",
        "getgid", "geteuid", "getegid", "setpgid", "getppid",
        "getpgrp", "setsid", "getgroups", "getresuid",
        "getresgid", "getpgid", "getsid", "capget", "capset",
        "rt_sigpending", "rt_sigtimedwait", "rt_sigsuspend",
        "sigaltstack", "arch_prctl", "setrlimit", "sync",
        "gettid", "futex", "set_thread_area", "epoll_create",
        "epoll_ctl", "epoll_wait", "set_tid_address", "restart_syscall",
        "clock_gettime", "clock_getres", "clock_nanosleep",
        "exit_group", "epoll_pwait", "openat", "newfstatat",
        "getdents64", "set_robust_list", "get_robust_list",
        "splice", "tee", "sync_file_range", "vmsplice",
        "move_pages", "accept4", "epoll_create1", "dup3",
        "pipe2", "recvmmsg", "preadv", "pwritev", "sendmmsg",
        "getrandom", "memfd_create", "pkey_mprotect", "statx"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}
EOF

# Seccomp profilini kullanarak konteyneri başlat
docker run -d 
  --security-opt seccomp=/etc/docker/seccomp/myapp-profile.json 
  --name secure-app 
  myapp:latest

AppArmor profili ise dosya sistemi erişimini ve ağ işlemlerini kontrol eder:

# AppArmor profili kontrolü
aa-status | grep docker

# Docker'ın varsayılan AppArmor profilini görmek için
cat /etc/apparmor.d/docker-default

# Konteyneri özel AppArmor profiliyle başlat
docker run --security-opt apparmor=docker-default nginx:alpine

Ağ Güvenliği ve İzolasyon

Varsayılan Docker kurulumunda tüm konteynerler aynı bridge network üzerinde birbirleriyle konuşabilir. Bu büyük bir güvenlik açığı. Bir konteynere giren saldırgan, tüm diğer konteynerlere de ulaşabilir.

# Servisler arası izolasyon için ayrı network'ler oluştur
docker network create --driver bridge frontend-net
docker network create --driver bridge backend-net
docker network create --driver bridge db-net

# Frontend sadece frontend-net'te
docker run -d --name nginx --network frontend-net nginx:alpine

# Backend hem frontend-net hem backend-net'te (API bridge noktası)
docker run -d --name api --network backend-net myapi:latest
docker network connect frontend-net api

# Database sadece backend-net'te, dışarıya kapalı
docker run -d --name postgres --network db-net postgres:15-alpine
docker network connect backend-net postgres

# Internal network (dış dünyaya erişimi yok)
docker network create --internal --driver bridge isolated-net
docker run -d --name internal-service --network isolated-net myservice:latest

Docker Compose ile bu yapıyı daha temiz yönetebilirsiniz:

version: "3.8"

services:
  nginx:
    image: nginx:alpine
    networks:
      - frontend
    ports:
      - "443:443"

  api:
    image: myapi:latest
    networks:
      - frontend
      - backend
    # Port expose etme, sadece network üzerinden erişim

  postgres:
    image: postgres:15-alpine
    networks:
      - backend
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true  # Dış dünyaya erişim yok

secrets:
  db_password:
    file: ./secrets/db_password.txt

no-new-privileges ve Diğer Güvenlik Seçenekleri

no-new-privileges seçeneği, bir konteynerdeki process’lerin setuid/setgid bitleri veya dosya capabilities’leri aracılığıyla yeni yetkiler kazanmasını engeller. Bu özellikle privilege escalation saldırılarına karşı önemli.

# no-new-privileges ile konteyner başlatma
docker run -d 
  --security-opt no-new-privileges:true 
  --name secure-nginx 
  nginx:alpine

# Kullanıcı namespace remapping - host root ile konteyner root'u ayır
# /etc/docker/daemon.json dosyasına ekle:
cat >> /etc/docker/daemon.json << 'EOF'
{
  "userns-remap": "default"
}
EOF

# Docker daemon'ı yeniden başlat
systemctl restart docker

# Konteyner içindeki root, host üzerinde çok düşük yetkili kullanıcıya map edilir
docker run --rm -it alpine id
# uid=0(root) gid=0(root) groups=0(root)
# ama host üzerinde bu aslında uid=100000 gibi bir kullanıcı

Secret Yönetimi

Dockerfile içinde ya da environment variable olarak şifre ve API key geçmek çok yaygın ve çok tehlikeli bir pratik. Image katmanları bu bilgileri kalıcı olarak saklar.

# Kötü: Dockerfile içinde secret
# FROM alpine
# ENV DB_PASSWORD=supersecret123  <- YAPMAYIN

# Kötü: docker run'da environment variable
# docker run -e DB_PASSWORD=supersecret123 myapp  <- YAPMAYIN

# İyi: Docker secrets kullanımı (Swarm mode)
echo "supersecret123" | docker secret create db_password -

docker service create 
  --name myapp 
  --secret db_password 
  myapp:latest

# Konteyner içinde /run/secrets/db_password dosyasından okuyun

# İyi: Harici secret manager ile entegrasyon
# HashiCorp Vault'tan secret çekme örneği
docker run -d 
  --name myapp 
  -e VAULT_ADDR=https://vault.internal:8200 
  -e VAULT_TOKEN_FILE=/run/secrets/vault_token 
  --secret vault_token 
  myapp:latest

Image Güvenlik Taraması

Güvenli bir konteyner sadece runtime ayarlarıyla değil, temiz bir image ile başlar. CI/CD pipeline’ınıza mutlaka image taraması ekleyin.

# Trivy ile image taraması (ücretsiz, açık kaynak)
# Kurulum
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin

# Image tarama
trivy image nginx:latest

# Sadece HIGH ve CRITICAL açıkları göster
trivy image --severity HIGH,CRITICAL nginx:latest

# CI/CD için çıktıyı JSON formatında al
trivy image --format json --output results.json myapp:latest

# Dockerfile taraması
trivy config Dockerfile

# Dockerfile best practices için hadolint
docker run --rm -i hadolint/hadolint < Dockerfile

Docker Daemon Güvenliği

Konteynerleri güvene almak yeterli değil, daemon’ın kendisini de sertleştirmeniz gerekiyor.

# /etc/docker/daemon.json için güvenli konfigürasyon
cat > /etc/docker/daemon.json << 'EOF'
{
  "icc": false,
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "no-new-privileges": true,
  "live-restore": true,
  "userland-proxy": false,
  "seccomp-profile": "/etc/docker/seccomp/default.json",
  "default-ulimits": {
    "nofile": {
      "Name": "nofile",
      "Hard": 64000,
      "Soft": 32000
    }
  }
}
EOF

# ICC (Inter-Container Communication) kapalıyken
# spesifik iletişime izin vermek için network kullanın
# icc: false konteynerlerin bridge üzerinden direkt konuşmasını engeller

systemctl restart docker

Docker socket’ına erişimi de kısıtlayın. Docker socket’ı root yetkisi demektir.

# Docker socket'ına erişimi sadece docker grubuna ver
ls -la /var/run/docker.sock
# srw-rw---- 1 root docker

# Bir uygulamanın docker socket'ına ihtiyacı varsa (CI/CD agent gibi)
# Docker socket proxy kullan
docker run -d 
  --name docker-socket-proxy 
  -v /var/run/docker.sock:/var/run/docker.sock 
  -e CONTAINERS=1 
  -e IMAGES=1 
  -e NETWORKS=0 
  -e VOLUMES=0 
  -e POST=0 
  tecnativa/docker-socket-proxy

CIS Benchmark ile Uyumluluk Kontrolü

Tüm bu ayarları yaptıktan sonra Docker CIS Benchmark’ı çalıştırarak eksiklerinizi tespit edebilirsiniz.

# Docker Bench Security aracı
docker run -it --net host --pid host --userns host --cap-add audit_control 
  -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST 
  -v /etc:/etc:ro 
  -v /lib/systemd/system:/lib/systemd/system:ro 
  -v /usr/bin/containerd:/usr/bin/containerd:ro 
  -v /usr/bin/runc:/usr/bin/runc:ro 
  -v /usr/lib/systemd:/usr/lib/systemd:ro 
  -v /var/lib:/var/lib:ro 
  -v /var/run/docker.sock:/var/run/docker.sock:ro 
  --label docker_bench_security 
  docker/docker-bench-security

Çıktıda [PASS], [WARN] ve [INFO] etiketleri göreceksiniz. WARN olanları öncelikli olarak ele alın.

Gerçek Dünya Senaryosu: Production Web Uygulaması

Tüm bu bilgileri bir araya getirip production ortamına hazır bir kurulum yapalım:

# Güvenli bir Node.js web uygulaması için tam Dockerfile
cat > Dockerfile << 'EOF'
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

FROM node:18-alpine
LABEL maintainer="[email protected]"

# Gereksiz paketleri kaldır
RUN apk del --no-cache apk-tools && 
    rm -rf /var/cache/apk/*

WORKDIR /app

# Non-root kullanıcı
RUN addgroup -g 1001 -S nodejs && 
    adduser -S nodeuser -u 1001 -G nodejs

# Dosyaları kopyala
COPY --from=builder --chown=nodeuser:nodejs /app/node_modules ./node_modules
COPY --chown=nodeuser:nodejs src/ ./src/
COPY --chown=nodeuser:nodejs package.json ./

USER nodeuser

# Port tanımla ama bind etme
EXPOSE 3000

# Health check ekle
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"

CMD ["node", "src/server.js"]
EOF

# Bu image'ı güvenli şekilde çalıştır
docker run -d 
  --name production-app 
  --read-only 
  --tmpfs /tmp:rw,noexec,nosuid,size=100m 
  --cap-drop=ALL 
  --cap-add=NET_BIND_SERVICE 
  --security-opt no-new-privileges:true 
  --security-opt seccomp=/etc/docker/seccomp/nodejs-profile.json 
  --network app-network 
  --memory=512m 
  --cpus=1 
  --pids-limit=100 
  --restart unless-stopped 
  production-app:latest

Sonuç

Konteyner güvenliği tek bir önlemle sağlanmıyor. Katmanlı bir yaklaşım gerekiyor. En azından şu beş temel adımı atlamazsanız zaten büyük çoğunluğu kapatmış olursunuz:

  • Non-root kullanıcı: Konteynerlerinizi asla root olarak çalıştırmayın
  • Minimal image: Distroless veya Alpine tabanlı ince image’lar kullanın
  • Read-only filesystem: Mümkün olduğunda dosya sistemi yazılabilirliğini kaldırın
  • Capability kısıtlama: --cap-drop=ALL ile başlayıp sadece gerekli olanları ekleyin
  • Ağ segmentasyonu: Konteynerleri işlevlerine göre ayrı networklere koyun

Bunları CI/CD sürecinize entegre edin, Trivy gibi araçlarla image taramasını otomatik hale getirin ve Docker Bench’i periyodik çalıştırın. Güvenlik bir kez yapıp unutulan bir şey değil, süregelen bir pratik. Base image’larınızı her ay güncelleyin, eski ve kullanılmayan konteynerleri temizleyin, logları düzenli inceleyin.

Başlangıç için bunaltıcı gelebilir, ama küçük adımlarla başlayın. Önce non-root kullanıcıya geçin. Sonra capability kısıtlaması yapın. Her adımda sistemlerinizin çok daha güvenli hale geldiğini göreceksiniz.

Yorum yapın