~/yazilar/docker-guvenligi

Docker'da güvenliği sıkılaştırmak

7 dk okuma

docker grubundaki bir kullanıcı, sudo yetkisi olmasa bile şu tek satırla host'un kök dizinine root olarak girebiliyor:

docker run --rm -it -v /:/host alpine:3 chroot /host sh

Docker'ı güvenli kullanmak büyük ölçüde bu yetkiyi kimin ve neyin eline verdiğini daraltmak. Aşağıdakileri getirdikleri koruma ve çıkardıkları zahmete göre kabaca sıraladım. Hepsini birden uygulamak zorunda değilsin, tek sunucuda bile ilk üçü epey fark ediyor.

docker.sock neden root demek

Docker CLI daemon'la /var/run/docker.sock üzerinden konuşuyor. Bu sokete erişebilen bir istemci, daemon'dan istediği mount'u ve yetkiyi isteyebiliyor. Bir container host'un kök dizinini kendine bağlayacak şekilde başlatılabildiği için, soket erişimi pratikte host üzerinde root erişimine eşit. docker grubunun root sayılmasının sebebi de bu: gruptaki kullanıcı soketle konuşabiliyor.

Bu yüzden önemli olan nokta soketi kimin görebildiği. İki yerde dikkat gerekiyor.

Birincisi docker grubu. Bu gruba eklediğin kullanıcı, parola sorulmadan root yetkisine ulaşıyor. Gruba sadece zaten sudo yetkisi olan, yani zaten bu yetkiye sahip kişileri ekle. Kim olduğunu kontrol et:

getent group docker

İkincisi soketi bir container'a bağlamak. Traefik container keşfi için, Portainer yönetim için, CI runner'lar build için, otomatik güncelleme araçları yeniden başlatma için bu soketi istiyor. Sorun şu: soketi gören bir container ele geçirilirse, saldırgan artık daemon'a komut verebiliyor ve host'u bağlayan yeni bir container başlatarak container sınırının dışına çıkabiliyor. Yani soketi verdiğin container, güvenliği en az o container kadar sağlam olan bir şey haline geliyor.

Sık rastlanan bir yanılgı: soketi :ro ile bağlamak bunu çözmüyor. -v /var/run/docker.sock:/var/run/docker.sock:ro sadece soket dosyasını salt okunur yapıyor, ama bir Unix soketine bağlanıp API isteği göndermek "dosyaya yazmak" sayılmadığı için container yine de her isteği gönderebiliyor. Okuma/yazma ayrımı burada koruma sağlamıyor.

Pratikte üç yaklaşımdan birini seçiyorum:

  • Soketi hiç bağlamamak. Aracın buna gerçekten ihtiyacı var mı diye sor. Çoğu zaman alternatif bir yol var.
  • Bir socket proxy koymak. tecnativa/docker-socket-proxy gibi bir ara katman, container ile daemon arasına girip sadece belli API uçlarına (örneğin container listeleme) izin veriyor, container oluşturma gibi tehlikeli uçları kapatıyor. Traefik yazısında bunu kuruyorum.
  • Rootless Docker kullanmak. Böylece soket root'a değil, normal bir kullanıcıya bağlı oluyor ve kaçış durumunda eline geçen yetki o kullanıcıyla sınırlı kalıyor.

Rootless Docker

Rootless mode, daemon'ı ve container'ları root yerine normal bir kullanıcı olarak çalıştırıyor. User namespace sayesinde container içindeki root, host'ta yetkisiz bir kullanıcıya eşleniyor. Bir container host'a kaçsa bile ele geçen yetki o kullanıcınınki oluyor, root değil.

Aynı makinede root olarak çalışan daemon'ı kullanmayacaksan önce onu kapat. Docker'ın dokümanı da böyle öneriyor, iki daemon yan yana durunca hangi sokete konuştuğunu karıştırmak çok kolay. Kurulum script'i ise sudo'suz, normal kullanıcı olarak çalışıyor:

sudo systemctl disable --now docker.service docker.socket
sudo apt-get install -y uidmap dbus-user-session
dockerd-rootless-setuptool.sh install

Script rootless adında bir Docker context'i oluşturuyor. CLI'ı ona yönlendirmek için DOCKER_HOST değişkeni de kullanılabiliyor ama ikisini birden yapmaya gerek yok, ben context'i seçiyorum:

docker context use rootless
docker info --format '{{.SecurityOptions}}'
[name=seccomp,profile=builtin name=rootless name=cgroupns]

name=rootless satırını görüyorsan çalışıyor. Kullanıcı çıkış yapınca servisin durmaması için lingering açmak gerekiyor:

sudo loginctl enable-linger "$USER"

Rootless modun bedelleri var, bunları bilerek geçmek lazım. 1024'ün altındaki portları doğrudan dinleyemiyorsun (80 ve 443 dahil), ek ayar gerekiyor. Ağ performansı varsayılan slirp4netns ile biraz düşük. Bazı depolama sürücüleri ve bazı özellikler desteklenmiyor. Tek sunucuda kendi servislerini çalıştırmak için rahat, ama her senaryoya uymuyor. Ben internete bakan, tek uygulamalı sunucularda rootless'ı tercih ediyorum.

Her container'a en az yetki

Rootless'a geçmesen de her container'ı daha dar yetkiyle çalıştırabilirsin. Üç ayar epey fark ediyor ve üçü de Compose dosyasında birkaç satır.

Yeni yetki kazanmayı kapat. no-new-privileges, container içindeki bir sürecin setuid binary'ler aracılığıyla yetki yükseltmesini engelliyor.

Salt okunur kök dosya sistemi. Uygulamanın kendi dosya sistemine yazması gerekmiyorsa read_only: true ile kökü salt okunur yap. Yazması gereken geçici dizinler için tmpfs ver. Böylece container'a sızan biri kalıcı bir şey bırakamıyor, sistem dosyalarını değiştiremiyor.

Bütün capability'leri düşür, gerekeni geri ekle. Container'lar varsayılan olarak bir dizi Linux capability ile başlıyor. Çoğu web uygulamasının hiçbirine ihtiyacı yok. Hepsini düşürüp (cap_drop: ALL) gerçekten gerekeni geri eklemek, ihtiyaç olmayan yetkileri ortadan kaldırıyor.

Compose dosyasındaki web servisine uygulanmış hali:

  web:
    image: uygulama-web:1.0.0
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    # 1024 altı porta bağlanmak gerekseydi:
    # cap_add:
    #   - NET_BIND_SERVICE
    networks:
      - frontend
      - backend

Önceki yazıdaki image zaten root olmayan bir kullanıcıyla çalışıyordu. Capability düşürmek bununla birlikte işliyor: hem root değilsin hem de root olsan bile elinde yetki yok.

Salt okunur köke geçince uygulama ilk başlatmada bir yere yazmaya çalışıp çökebiliyor:

OSError: [Errno 30] Read-only file system: '/app/.cache'

Hangi dizine yazmaya çalıştığını bulup onu ya tmpfs olarak ya da bir volume olarak ver. Nereye yazdığını bilmiyorsan bir süre salt okunur olmadan çalıştırıp docker diff <container> ile container'ın dosya sisteminde ne değiştirdiğine bakabilirsin.

docker diff uygulama-web-1
C /tmp
A /tmp/cache
C /app/instance

A eklenen, C değişen dosya. Bu listedeki yazılabilir olması gereken yerleri tmpfs ya da volume'a çeviriyorsun, gerisi salt okunur kalabiliyor.

Bir container'ın gerçekte hangi ayarlarla çalıştığını doğrulamak için:

docker inspect --format \
  'ro={{.HostConfig.ReadonlyRootfs}} capdrop={{.HostConfig.CapDrop}} nnp={{.HostConfig.SecurityOpt}}' \
  uygulama-web-1

Trivy ile image taraması

Küçük ve root olmayan bir image bile, temel aldığı işletim sistemi paketlerinde ya da uygulama bağımlılıklarında bilinen açıklar taşıyabiliyor. Bunu görmek için image'ı tarıyorum. Trivy bunu hem OS paketleri hem de dil bağımlılıkları için yapıyor ve kendisi de bir container olarak çalışabiliyor:

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v trivy-cache:/root/.cache/ \
  aquasec/trivy:0.55.0 image uygulama-web:1.0.0
uygulama-web:1.0.0 (debian ...)
Total: 3 (UNKNOWN: 0, LOW: 1, MEDIUM: 1, HIGH: 1, CRITICAL: 0)
...

Burada Trivy'ye docker.sock'u verdim, çünkü yereldeki image'ı okuması gerekiyor. Bu, az önce anlattığım riskin ta kendisi. Soket vermemek için image'ı önce bir tar dosyasına kaydedip Trivy'ye dosya olarak verebilirsin. Image bir registry'de duruyorsa Trivy onu adıyla registry'den de çekip tarayabiliyor.

Taramada çıkan her açık hemen sömürülebilir değil. Pratikte önce --severity HIGH,CRITICAL ile ciddi olanlara, sonra --ignore-unfixed ile düzeltmesi yayınlanmış olanlara odaklanıyorum. Düzeltmesi olmayan bir açık için yapacak bir şey yok, düzeltmesi olanı ise base image'ı güncelleyip yeniden build alarak çoğu zaman kapatıyorum. Soketsiz hali, filtrelerle birlikte:

docker save uygulama-web:1.0.0 -o uygulama-web.tar
docker run --rm -v trivy-cache:/root/.cache/ \
  -v "$PWD/uygulama-web.tar:/tmp/image.tar:ro" \
  aquasec/trivy:0.55.0 image --input /tmp/image.tar \
  --severity HIGH,CRITICAL --ignore-unfixed

Bunu CI'a koymak en temizi: image her build edildiğinde taranıyor, ciddi ve düzeltilebilir bir açık varsa (--exit-code 1) build kırılıyor. Böylece yeni açıklar fark edilmeden üretime gitmiyor.

Parolalar nereye

Compose yazısında parolayı .env'den okuyup container'a ortam değişkeni olarak vermiştim. Pratik, ama ortam değişkenlerinin bir zayıflığı var: container'ın ayrıntılarını okuyabilen biri (ya da soketi gören bir araç) bunları görebiliyor:

docker inspect uygulama-db-1 --format '{{range .Config.Env}}{{println .}}{{end}}'
POSTGRES_PASSWORD=degistir-beni
POSTGRES_USER=uygulama
...

Değişken, alt süreçlere de miras kalıyor ve bir çökme dökümüne ya da loga düşebiliyor. Tek sunucuda Compose kullanıyorsan buna karşı en pratik koruma Docker secret'ları dosya üzerinden vermek. Resmi postgres image'ı, POSTGRES_PASSWORD yerine POSTGRES_PASSWORD_FILE ile bir dosya yolu kabul ediyor:

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

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

Parola artık /run/secrets/db_password altında bir dosyada duruyor, docker inspect çıktısında görünmüyor. Dosyanın izinlerini sıkılaştır ve repoya girmesini engelle:

mkdir -p secrets
openssl rand -base64 24 | tr -d '\n' > secrets/db_password.txt
chmod 600 secrets/db_password.txt
echo "secrets/" >> .gitignore

Bu, şifreli bir kasa değil: dosya sunucuda düz metin duruyor, sadece daha dar erişimli ve inspect çıktısına sızmıyor. Birden çok sunucu ya da gerçek bir gizli bilgi yönetimi gerekiyorsa HashiCorp Vault, SOPS ya da bulut sağlayıcının secret servisi gibi araçlara bakmak gerekiyor, ama o ayrı bir yazının konusu.

Uygulamanın kendi parolasını ortam değişkeni olarak alması şartsa, en azından değeri doğrudan Compose dosyasına yazma. .env ya da _FILE yolu her zaman daha iyi.

Küçük bir kontrol listesi

Yeni bir sunucuyu üretime almadan önce baktıklarım:

  • docker grubunda sadece olması gereken kişiler var (getent group docker)
  • docker.sock bağlı container yok, varsa socket proxy arkasında
  • Mümkün olan yerde rootless Docker
  • Container'larda no-new-privileges ve cap_drop: ALL
  • Yazması gerekmeyen container'larda read_only: true
  • Image'lar belirli etiketle, latest yok
  • Image'lar CI'da Trivy ile taranıyor
  • Parolalar .env ya da secret dosyasında, Compose dosyasında değil
  • Veritabanı ve Redis portları yayınlanmıyor (bkz. ilk yazıdaki UFW notu)

Son maddenin sebebi ilk yazıda anlattığım iptables meselesi: UFW'de kapalı görünen bir port, Docker onu yayınlıyorsa dışarıdan erişilebilir durumda. Dışarı açılması gereken servisleri tek bir kapıdan vermek bu yüzden daha düzenli.

Sıradaki yazı: Traefik ile reverse proxy ve otomatik HTTPS