Опирается на правила: R-SEC-IMG-1R-SEC-IMG-4 и R-SEC-IMG-X1R-SEC-IMG-X2 из Security Style Guide → раздел 4. Container/image-уязвимости.

Важно знать

  • Trivy обязателен для всех Docker-образов — сканирует base image, OS-пакеты, Python-пакеты из requirements.txt/uv.lock.
  • HIGH/CRITICALexit-code: 1. ignore-unfixed: true — CVE без патча от upstream не блокируют, идут в отдельную задачу.
  • Base image: python:3.12-slim или gcr.io/distroless/python3-debian12:nonroot; никогда python:latest, ubuntu:latest, :latest без digest.
  • Digest-pin обязателен — @sha256:.... Mutable-тег со временем меняется; сборка двухнедельной давности даёт другой образ.
  • Non-root user USER 1000:1000 в конце Dockerfile. Без — CVE → escape → root в контейнере → доступ к metadata-service.
  • HEALTHCHECK или K8s liveness/readiness probe — обязательно; без них зависший uvicorn-воркер не детектируется.
  • Multi-stage build — устанавливаем зависимости в builder, копируем site-packages в финальный образ; pip, setuptools и build-инструменты не попадают в production-layer.

Контейнер — это полный стек в одном файле: твой FastAPI-сервис + CPython + libc + OS packages. Уязвимость в любом слое — уязвимость в проде. Правила R-SEC-IMG-* задают: сканер на финальный образ, минимальный base, non-root user, явный digest.

Trivy в CI

R-SEC-IMG-1: запускается после docker build, до docker push.

name: Build and scan
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t order-service:${{ github.sha }} .

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: order-service:${{ github.sha }}
          severity: HIGH,CRITICAL
          exit-code: 1
          ignore-unfixed: true
          format: sarif
          output: trivy-results.sarif

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

      - name: Push image
        if: success()
        run: docker push order-service:${{ github.sha }}

Параметры:

  • severity: HIGH,CRITICAL — только серьёзные. LOW/MEDIUM не блокируют.
  • exit-code: 1 — failed scan = failed CI (R-SEC-1).
  • ignore-unfixed: true — CVE без патча от upstream откладываются в задачу, не блокируют сборку. Без этого флага собрать python:3.12-slim без единого CVE практически невозможно.
  • SARIF output → GitHub Code Scanning, findings в Security tab (R-SEC-FIND-3).

Trivy на Python-образе сканирует:

  1. OS packages (deb/apk — зависит от base).
  2. Python dependencies — разбирает site-packages внутри образа, идентифицирует пакеты через METADATA.
  3. Misconfigurations — Docker best practices (--security-checks config).

Важно: pip-audit сканирует PyPA Advisory DB на этапе сборки (dep-слой), Trivy — финальный образ включая транзитивные OS-зависимости. Оба нужны.

Base image

R-SEC-IMG-2: минимальный, известный, digest-pinned.

Вариант 1: python:3.12-slim

FROM python:3.12-slim@sha256:a1b2c3d4e5f6...

slim — Debian без лишних пакетов; остаются системные библиотеки, необходимые CPython. Меньше surface attack, чем полный python:3.12. Есть sh и базовые утилиты — удобнее для отладки, чем distroless.

Вариант 2: Distroless Python

FROM gcr.io/distroless/python3-debian12:nonroot@sha256:...

Distroless — образы Google без OS shell, без package manager, без curl. Только CPython + системные библиотеки. После RCE атакующий не может apt install, не может wget. :nonroot — pre-configured non-root user (UID 65532).

Цена distroless — отладка сложнее: нет sh, нет cat. Для production это правильный выбор.

Multi-stage build

FROM python:3.12-slim@sha256:a1b2c3d4e5f6... AS builder

WORKDIR /build

COPY requirements.txt .
RUN pip install --no-cache-dir --target=/build/deps -r requirements.txt

FROM python:3.12-slim@sha256:a1b2c3d4e5f6...

RUN addgroup --gid 1000 app && adduser --uid 1000 --gid 1000 --no-create-home --disabled-password app

WORKDIR /app
COPY --from=builder /build/deps /app/deps
COPY --chown=app:app src/ /app/src/

ENV PYTHONPATH=/app/deps
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

USER 1000:1000

EXPOSE 8000

ENTRYPOINT ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

builder-stage устанавливает зависимости; финальный stage берёт только site-packages. pip, setuptools, wheel не попадают в production-слой — меньше пакетов, меньше CVE.

Digest-pin

# ХОРОШО
FROM python:3.12-slim@sha256:a1b2c3d4e5f6...

# СНОСНО — explicit version tag
FROM python:3.12.9-slim

# ПЛОХО — тег мутирует
FROM python:3.12-slim

# КАТАСТРОФА
FROM python:latest
FROM ubuntu:latest

:latest или mutable tags меняются независимо от кода — сборка двухнедельной давности даёт другой образ. Digest (@sha256:) immutable; обновляешь явно через PR (Renovate умеет обновлять digest автоматически).

Non-root user

R-SEC-IMG-3: USER 1000:1000 в конце Dockerfile.

FROM python:3.12-slim@sha256:a1b2c3d4e5f6...

RUN addgroup --gid 1000 app \
    && adduser --uid 1000 --gid 1000 --no-create-home --disabled-password app

WORKDIR /app
COPY --chown=app:app . /app/

RUN pip install --no-cache-dir -r requirements.txt

USER 1000:1000

EXPOSE 8000

ENTRYPOINT ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Что даёт non-root:

  • CVE escape через баг в CPython или uvicorn → попадаешь в контейнер как app, не root.
  • Нет доступа к privileged operations (mount, netlink, kernel modules).
  • AWS/GCP metadata service (169.254.169.254) часто ограничен по UID — non-root снижает шанс достать Instance Metadata.

Для сервисов типа OrderService или CustomerService — никакой причины запускаться от root нет.

В K8s — дополнительно securityContext:

spec:
  containers:
    - name: order-service
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        readOnlyRootFilesystem: true
        allowPrivilegeEscalation: false
        capabilities:
          drop:
            - ALL

readOnlyRootFilesystem: true — даже после проникновения нельзя записать на диск. drop: ALL capabilities — нет даже CAP_NET_RAW. Для ProductService с FastAPI это safe default.

HEALTHCHECK или K8s probes

R-SEC-IMG-4: runtime-проблемы детектируются.

Вариант 1: HEALTHCHECK в Dockerfile

FastAPI-сервис с эндпоинтом /health:

from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
async def health() -> dict[str, str]:
    return {"status": "ok"}
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

wget может отсутствовать в slim-образе. curl — тоже. Fallback на python -c с urllib.request безопасен, python всегда есть в образе.

Вариант 2: K8s liveness/readiness

spec:
  containers:
    - name: order-service
      livenessProbe:
        httpGet:
          path: /health
          port: 8000
        initialDelaySeconds: 15
        periodSeconds: 10
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /ready
          port: 8000
        initialDelaySeconds: 5
        periodSeconds: 5
from fastapi import FastAPI
from contextlib import asynccontextmanager

_ready = False

@asynccontextmanager
async def lifespan(app: FastAPI):
    global _ready
    # инициализация: DB-пул, кэш
    _ready = True
    yield
    _ready = False

app = FastAPI(lifespan=lifespan)

@app.get("/health")
async def liveness() -> dict[str, str]:
    return {"status": "ok"}

@app.get("/ready")
async def readiness() -> dict[str, str]:
    if not _ready:
        from fastapi import HTTPException
        raise HTTPException(status_code=503, detail="not ready")
    return {"status": "ready"}

liveness — JVM-аналог: процесс жив. readiness — готов принимать трафик. В K8s deployments probes достаточно; HEALTHCHECK дублировать не обязательно. Для docker-compose и standalone — HEALTHCHECK нужен.

Без probe зависший uvicorn-воркер (deadlock, event-loop saturation, OOM) не детектируется. Kubernetes продолжает слать трафик на dead pod.

Что запрещено

АнтипаттернПравилоЧто взамен
USER root или отсутствие USERR-SEC-IMG-X1USER 1000:1000 в конце Dockerfile
:latest base imageR-SEC-IMG-X2digest-pin (@sha256:...)
Без Trivy в CIR-SEC-IMG-1scan после build, до push
Trivy с --exit-zero или без exit-code: 1R-SEC-IMG-1exit-code: 1 на HIGH/CRITICAL
Trivy без ignore-unfixed: trueR-SEC-IMG-1unfixed — отдельная задача, не блокирует
Без HEALTHCHECK / probesR-SEC-IMG-4минимум K8s probes или HEALTHCHECK
pip install в финальном stage (не multi-stage)R-SEC-IMG-2builder-stage, копировать только deps
Без securityContext в K8sR-SEC-IMG-3runAsNonRoot: true + capabilities.drop: ALL
PYTHONDONTWRITEBYTECODE не выставленR-SEC-IMG-2ENV PYTHONDONTWRITEBYTECODE=1 (нет .pyc в image)

Куда дальше

  • SAST по коду — bandit, semgrep, ruff S-правила; code-level vulnerabilities.
  • CVE в зависимостях — pip-audit, Renovate; Python deps, не OS packages.
  • Секреты в коде и истории — Gitleaks; не хранить в Docker layers через ENV.
  • Реакция на findings — SLA по severity; suppressions со сроком.
  • Криптография — argon2-cffi, secrets, AES-GCM; что запрещено в Python.