Docker security: Hands-on guide

Maximilian Tellmann

Absichern von Docker Containern, durch die Nutzung von Best Practices in DockerFiles und Docker Compose.

Einführung

Es ist sehr wahrscheinlich im Alltag mit containerisierten Anwendungen in Berührung zu kommen, ohne sich dessen bewusst zu sein. In einer Zeit, in der sich der Trend der Unternehmen weiterhin stark in Richtung Cloud bewegt, gewinnen Container immer mehr an Bedeutung. Es ist von hoher Relevanz, diese meist containerisierten Anwendungen mit Best Practices abzusichern, um ein gewisses Maß an Grundsicherheit zu erreichen. Container bieten einen großen Mehrwert, vor allem in Bezug auf Isolation, Skalierung, Kontrolle und Nutzung der Ressourcen.

Methoden zur Bereitstellung von Anwendungen

  1. Installation der Anwendung auf einem neuen System und Anpassung an die Umgebung.
  2. Bereitstellung der Anwendung in einem Container, der die Umgebung mit sich bringt.

Viele Anwendungen bieten entweder selbst oder durch von der Community entwickelte Container-Lösungen an, die einfach einzurichten sind und minimale Anstrengungen für die Installation erfordern. Dies ist jedoch nicht immer der Fall oder durch die Individualisierung einer Applikation, muss diese selbst in einem Container verpackt werden.

Best Practices für den sicheren Build Prozess von Docker Containern

Um Flüchtigkeitsfehler bei der Entwicklung zu vermeiden, ist es wichtig, alle Dateien, die im Container verwendet werden sollen, in einem separaten Verzeichnis zu speichern. Dadurch wird sichergestellt, dass bei der Arbeit mit relativen Pfaden die benötigten Dateien verfügbar sind und versehentliche Fehler vermieden werden.

  • Updated Images
    Die Aktualisierung der Images ist eine der wichtigsten Maßnahmen, die getroffen werden können. Es sollte darauf geachtet werden, dass die Images für den produktiven Betrieb geeignet sind. Dazu gehört auch, dass Long-Term-Support (LTS) Images gegenüber Bleeding Edge Versionen bevorzugt werden sollten.
  • Trusted Images
    Der FROM Befehl in einer Dockerfile beschreibt, welches Image für den Docker Container verwendet werden soll. Es gibt keine Garantie dafür, dass diese Images weiterhin maintained und zu 100 % frei von Sicherheitslücken sind. Es ist ebenfalls wichtig, signierte Images durch den Docker Content Trust zu verwenden, da so garantiert wird, dass diese nicht durch Dritte verändert wurden. Empfehlenswert ist auch, die Images zuvor auf Docker Hub zu prüfen, da dort aktuelle Sicherheitslücken aufgelistet werden. Als Beispiel hier die Analyse des GCC Containers auf Docker Hub.
FROM alpine:3.19
  • Image Version festsetzten
    Docker Tags sind veränderbar, was nicht garantiert, dass der Tag IMAGE:1.0 immer 1.0 ist. Die Version 1.0 könnte zum Beispiel ein Alias für die Patch-Version 1.1 sein. Dies ist ein valider Ansatz, da er es ermöglicht, Sicherheits-Patches auf ein Image anzuwenden, ohne dass der IT-Administrator die Imageversion für jeden Container ändern muss. Dennoch zeigt sich hier das Problem, dass wenn man eine bestimmte Version eines Images verwenden möchte, noch weitere Maßnahmen ergreifen muss. Für jeden Release eines Images wird ein entsprechender Hash generiert, mit dem ein Container eindeutig identifiziert werden kann. Die Verwendung des Hashes im Dockerfile kann dem FROM Befehl hinzugefügt werden. Diese Hash kann direkt über die Docker Hub eingesehen werden.
FROM alpine:3.19@sha256:6457d53fb065d6f250e1504b9bc42d5b6c65941d57532c072d929dd0628977d0
  • Minimal Images
    Das Grundprinzip der Containerisierung besteht darin, nur das in den Container aufzunehmen, was die Anwendung benötigt. Dies bezieht sich nicht nur auf Binaries, sondern auch auf Bibliotheken. Lightweight Linux Distributionen wie Alpine sollten hier das Minimum sein. Images können auch durch die Verwendung von distroless-Images wie gcr.io/distroless/base-debian12 gesichert werden. Diese enthalten nur die Basispakete und nur die Bibliotheken, die zur Ausführung eines Programms benötigt werden, das dynamische Bibliotheken erfordert. Wenn auch diese nicht notwendig sind und die Anwendung statisch gebaut wurde, kann auch das Image gcr.io/distroless/static-debian12 verwendet werden, welches dann auch diese “überflüssigen” dynamische Bibliotheken entfernt.
  • Multistage builds
    Im Sinne des minimal Image Ansatzes wird auch empfohlen, mehrstufige Images zu verwenden, wenn eine Kompilierung von Dateien erforderlich ist. Diese sind nützlich, da sie es ermöglichen, den Build Prozess vom veröffentlichten Docker Container zu trennen. So sollten beispielsweise Build Tools wie GCC nicht in den endgültigen Container aufgenommen werden.
# "builder" stage
FROM gcc:9.5.0-bullseye@sha256:44a9e26a95ca57839a1ac37106f9d93025d508072aa6ea84fcda696737a80bc1 as builder
WORKDIR /my-app
COPY app-src ./host-path
RUN |BUILD COMMAND|

# Final stage were we copy artifacts from "builder"
FROM alpine:3.19@sha256:6457d53fb065d6f250e1504b9bc42d5b6c65941d57532c072d929dd0628977d0
COPY --from=builder |BUILD PATH| |CONTAINER PATH|
ENTRYPOINT [|CONTAINER PATH|]
  • Rootless containers
    Das Ausführen der Anwendung unter root ist nur in bestimmten Fällen notwendig und meistens ein Edge Case. Deshalb sollten im Container unterschiedliche Benutzer, UIDs und GIDs verwendet werden.
    Es sollte darauf geachtet werden, dass der Benutzer Zugriff auf die zu auszuführenden Anwendungsdateien hat.
# Create user and set owner and permissions
RUN adduser -D drunner && chown -R drunner |APP CONTAINER PATH|
USER drunner

Optional kann auch der Root Benutzer deaktiviert werden.
RUN chsh -s /usr/sbin/nologin root

  • File read / write permissions
    Es ist zu beachten, dass nur die Dateien und Pfade Schreibrechte haben sollten, die für die Ausführung der Anwendung notwendig sind. In jedem Fall sollte darauf geachtet werden, dass alle ausführbaren Dateien schreibgeschützt sind, um eine gewisse Grundsicherheit im Anwendungsablauf zu schaffen.
  • Ports
    Damit der Container sicher läuft, ist es wichtig, dass nur die Ports geöffnet werden, die für die Nutzung der Anwendung notwendig sind. Bei einer Webanwendung wären dies in diesem Fall 80 (HTTP) und 443 (HTTPS). Idealerweise sollte auch das entsprechende Kommunikationsprotokoll angegeben werden.
# Expose ports
EXPOSE 80/tcp
EXPOSE 443/tcp
  • Linting
    Tools wie hadolint helfen dabei, einige Best Practices in der Dockerfile zu überprüfen.

Ein Beispiel einer Dockerfile, welche diese Best Practices umsetzt, könnte wie folgt aussehen:

# "builder" stage
FROM gcc:9.5.0-bullseye@sha256:44a9e26a95ca57839a1ac37106f9d93025d508072aa6ea84fcda696737a80bc1 as builder
WORKDIR /my-app
COPY app-src .
RUN |BUILD COMMAND|

# Final stage were we copy artifacts from "builder"
FROM alpine:3.19@sha256:6457d53fb065d6f250e1504b9bc42d5b6c65941d57532c072d929dd0628977d0
COPY --from=builder |BUILD PATH| |APP CONTAINER PATH|

# Create user and set owner and permissions
RUN adduser -D drunner && chown -R drunner |APP CONTAINER PATH|
USER drunner
ENTRYPOINT [|APP CONTAINER PATH|]

# Expose ports
EXPOSE 80/tcp
EXPOSE 443/tcp

Nutzung von Docker Compose zum bauen von Images

Um sicherzustellen, dass der Build Prozess immer reproduzierbar bleibt, ist es von Vorteil, den Build in der Docker Compose Datei zu sichern. Der große Mehrwert dabei ist, dass durch die Versionierung nachvollzogen werden kann, welcher Build Prozess zu welcher Version der Container Images passt. Eine vollständige Liste der Optionen findet sich unter Docker Docs.

Docker bietet Argument und Environment Variablen zur Verwendung externer Parameter. Es ist zu beachten, dass Argumente nur während des Build Prozesses existieren und ab der Laufzeit des Containers nicht mehr verfügbar sind.

build:
    context: .
    dockerfile: linux-prod.Dockerfile
    platforms:
        - "linux/amd64"
    privileged: false
    network: none
    args:
        - VAR_XXX=foobar

Container sicher starten

An dieser Stelle sei erwähnt, dass im Folgenden nur Beispiele mit Docker Compose aufgeführt sind. Diese Einstellungen können jedoch mit Hilfe der Docker Dokumentation 1 zu 1 in Docker-CLI Kommandos übersetzt werden. Der optimale Ansatz, um vergessene Argumente und Fehlverhalten früherer Instanzen zu vermeiden, ist die Verwendung von Docker Compose.

Anhand des folgenden Beispiels werden nun Best Practices in die Konstruktion von Docker Compose Dateien integriert. In diesem Beispiel wird eine Webanwendung erstellt und ausgeführt, die sensible Dokumente generiert, um sie für Benutzer zum Herunterladen bereitzustellen.

services:
  secure-app:
    build:
      context: .
      dockerfile: app.DockerFile
    ports:
        - 80
  • Temporary mounts
    Beim Umgang mit sensiblen Dateien ist es wichtig, dass sie nicht für jedermann zugänglich sind und nicht dauerhaft gespeichert werden sollten. Unter Linux ist es möglich, tmpfs zu verwenden, das dafür sorgt, dass Dateien nur im flüchtigen Speicher des Hosts gespeichert werden. Nach dem Herunterfahren oder Neustart des Containers werden diese Dateien einfach verworfen.
tmpfs:
    - /app/sensitiv_data
  • Ports
    Die Optionen für die Portfreigabe innerhalb von Docker Compose bieten mehrere Möglichkeiten. So können Ports im Container Netzwerk, Docker Netzwerk oder im Host Netzwerk geöffnet werden. Dies ist jedoch teilweise davon abhängig, welches Netzwerk dem Container zugewiesen wurde und welche Capabilities (Berechtigungen) dem Container zu gewissen sind.
ports:
    - "127.0.0.1:8080:80"

Anwendungen, die aus dem Internet erreichbar sind, sollten immer nur über eine sichere Verbindung zugänglich sein. Um den Verwaltungsaufwand zu reduzieren, empfiehlt sich der Einsatz einer Reverse Proxy, über die die entsprechenden Zertifikate zentral ausgestellt werden und bestimmte Regeln bezüglich der Allow- und Blocklist gepflegt werden. Um Ports nur innerhalb des Docker Netzwerks zu öffnen, sollte der keywoard expose verwendet werden. Normalerweise befinden sich die Anwendungen in separaten Containernetzen, was zur Isolation beiträgt. Um die Dienste jedoch zugänglich zu machen, sollte man darauf achten, wie die Ports geöffnet werden.

  • Capabilities
    Berechtigungen sollten immer sorgfältig gehandhabt werden, wobei der Allowlist Ansatz verfolgt werden sollte. Es besteht auch die Möglichkeit, mit den Schlüsselwörtern cap_drop Berechtigungen zu entfernen und cap_add Berechtigungen hinzuzufügen.
cap_drop:
    - ALL
cap_add:
    - CHOWN
  • UID und GID remapping
    Um sicherzustellen, dass der interne Benutzer des Containers dem Host im Falle eines Breakouts keinen Schaden zufügen kann, muss die verwendete ID des Containers einem nicht privilegierten Benutzer des Hosts zugeordnet werden. Dadurch wird die Anwendung nicht beeinträchtigt, auch wenn der interne Benutzer des Containers der Benutzer “Root” sein muss.
cap_add:
    - SETGID
    - SETUID
user: 1000:1000
  • Permission lock
    Um zu verhindern, dass der Container weitere Berechtigungen erhält, ist die Verwendung von opt-securty relevant. Dies kann mit der Option no-new-privileges erreicht werden. Weiter Details können auf den Docker Docs eingesehen werden.
security_opt:
    - no-new-privileges:true

Eine beispielhafte Docker Compose Datei, die diese Best Practices umsetzt, könnte wie folgt aussehen:

services:
    secure-app:
        build:
            context: .
            dockerfile: app.DockerFile
        ports:
            - "127.0.0.1:8080:80"
        tmpfs:
            - /app/sensitiv_data
        volumes:
            - secure-data:/app/persistant_storage
        cap_drop:
            - ALL
        cap_add:
            - CHOWN
            - SETGID
            - SETUID
        user: 1000:1000
        security_opt:
            - no-new-privileges:true

volumes:
  secure-data:

Posted

in

by

Maximilian Tellmann

Comments

Leave a Reply