From 931243dd604f4accef02332170d776c36465dfcc Mon Sep 17 00:00:00 2001 From: Finn Stutzenstein Date: Wed, 21 Oct 2026 18:09:30 +0000 Subject: [PATCH] Harden the container: non-root, no build tools, read-only, stdout logging The app ran as root, and the production image contained gcc, vim, wget and gnupg. Code execution in the container would have allowed changing the app code and fetching or compiling further tools. - Multi-stage build: the wheels (psycopg2) are built in a build stage. The runtime image only contains libpq5, postgresql-client and wait-for-it (724 MB -> 298 MB). - The app runs as the system user max (uid/gid 10001). The code is owned by root and precompiled, the user has no writable directory. - Log to stdout only; docker collects it. The log file under /app/log was lost on every container recreation anyway. - docker-compose: read-only filesystem (tmpfs for /tmp), no capabilities, no-new-privileges and log rotation (5 x 10 MB). - Add a .dockerignore and set MAX_ORIGIN in docker-compose.yml. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 8 ++++++++ .gitignore | 1 - Dockerfile | 33 ++++++++++++++++++++++++++------- Makefile | 3 +-- docker-compose.yml | 12 ++++++++++++ max/log.py | 30 ++++++++---------------------- 6 files changed, 55 insertions(+), 32 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..844960f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.devcontainer +.vscode +.claude +docs +log +**/__pycache__ +**/*.pyc diff --git a/.gitignore b/.gitignore index 0f55f8d..0d20b64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ *.pyc -log/ diff --git a/Dockerfile b/Dockerfile index c5be319..1e15bc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,39 @@ +# Build stage: compile the wheels (psycopg2 needs gcc and libpq-dev) +FROM python:3.15-slim-trixie AS build + +RUN apt-get update && \ + apt-get install --no-install-recommends -y gcc libc-dev libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY requirements.txt . +RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt + +# Runtime stage: no compiler and no tools besides what db-setup.sh needs FROM python:3.15-slim-trixie -RUN apt -y update && apt -y upgrade && \ - apt install --no-install-recommends -y wait-for-it lsb-release wget gnupg2 gcc libpq-dev libc-dev vim postgresql-client && apt clean all +RUN apt-get update && apt-get upgrade -y && \ + apt-get install --no-install-recommends -y libpq5 postgresql-client wait-for-it && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=build /wheels /wheels +RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels + +RUN groupadd --system --gid 10001 max && \ + useradd --system --uid 10001 --gid max --no-create-home --shell /usr/sbin/nologin max WORKDIR /app -COPY requirements.txt . -RUN pip install -U -r requirements.txt - -RUN mkdir log - ENV PYTHONPATH=/app/ ENV FLASK_APP=max.app +# Owned by root, so the app cannot modify its own code COPY max/ ./max COPY scripts/prod/ . +# Precompile, since the app cannot write __pycache__ at runtime +RUN python -m compileall -q max + +USER max ENTRYPOINT ["./entrypoint.sh"] CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:8000", "max.app:app"] diff --git a/Makefile b/Makefile index d6fe6fc..9b53868 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,7 @@ run-dev: FLASK_APP=max.app FLASK_DEBUG=1 flask run --host 0.0.0.0 --port 8757 run-dev-as-prod: - mkdir -p log - FLASK_APP=max.app LOG_DIR=log MAX_ORIGIN=http://localhost:8757 gunicorn -w 1 -b 0.0.0.0:8757 max.app:app + FLASK_APP=max.app MAX_ORIGIN=http://localhost:8757 gunicorn -w 1 -b 0.0.0.0:8757 max.app:app run-cleanup: scripts/dev/cleanup.sh diff --git a/docker-compose.yml b/docker-compose.yml index 1469f99..94f2a10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,18 @@ services: - PGSQL_PASSWORD=max - PGSQL_NAME=max - MAX_ORIGIN=https://max.finn.st + read_only: true + tmpfs: + - /tmp # gunicorn worker heartbeat files + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" networks: - postgresql - uplink diff --git a/max/log.py b/max/log.py index 53dbb3e..ee1fc37 100644 --- a/max/log.py +++ b/max/log.py @@ -1,37 +1,23 @@ import logging -import os import sys from logging.config import dictConfig def init_logging(app): - handlers = { - "stdout": { - "class": "logging.StreamHandler", - "stream": sys.stdout, - "formatter": "default", - }, - } - active_handlers = ["stdout"] - if not app.debug: - handlers["file"] = { - "class": "logging.handlers.RotatingFileHandler", - "filename": os.path.join(os.environ.get("LOG_DIR", "/app/log"), "max.log"), - "formatter": "default", - "maxBytes": 1024 * 1024, - "backupCount": 5, - } - active_handlers.append("file") - dictConfig( { "version": 1, "formatters": { "default": {"format": "[%(asctime)s] [%(levelname)s]: %(message)s"} }, - "handlers": handlers, - "root": {"level": "INFO", "handlers": active_handlers}, - "gunicorn": {"level": "INFO", "handlers": active_handlers}, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "stream": sys.stdout, + "formatter": "default", + }, + }, + "root": {"level": "INFO", "handlers": ["stdout"]}, } ) logger = logging.getLogger(app.name)