~/yazilar/docker-guvenligi
Docker'da güvenliği sıkılaştırmak
içindekiler
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 shDocker'ı 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-proxygibi 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 installScript 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-1C /tmp
A /tmp/cache
C /app/instanceA 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-1Trivy 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.0uygulama-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-unfixedBunu 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.txtParola 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/" >> .gitignoreBu, ş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:
-
dockergrubunda sadece olması gereken kişiler var (getent group docker) -
docker.sockbağlı container yok, varsa socket proxy arkasında - Mümkün olan yerde rootless Docker
- Container'larda
no-new-privilegesvecap_drop: ALL - Yazması gerekmeyen container'larda
read_only: true - Image'lar belirli etiketle,
latestyok - Image'lar CI'da Trivy ile taranıyor
- Parolalar
.envya 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