Опирается на правила: 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-пакеты, библиотеки.
  • HIGH/CRITICALexit-code: 1. ignore-unfixed: true — CVE без патча от upstream не блокируют.
  • Base image: eclipse-temurin:21-jre-alpine или gcr.io/distroless/java21-debian12:nonroot.
  • Digest-pin обязателен — @sha256:.... Не :latest, не plain tag без digest для production.
  • Non-root user USER 1000:1000 в конце Dockerfile. Без — CVE escape → root.
  • HEALTHCHECK или K8s liveness/readiness — обязательно. Без — зависший JVM не детектируется.
  • :latest base image — сборка не воспроизводима.

Контейнер — это полная стек технологий в одном файле: твой Java-код + JVM + libc + OS packages. Уязвимость в любом слое = уязвимость в проде. UCP формулирует: сканер на финальный образ, минимальный 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 myapp:${{ github.sha }} .

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: myapp:${{ 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 myapp:${{ github.sha }}

Параметры:

  • severity: HIGH,CRITICAL — только серьёзные. LOW/MEDIUM не блокируют.
  • exit-code: 1 — failed scan = failed CI.
  • ignore-unfixed: true — CVE, для которых upstream не выпустил патч, не блокируют. Иначе невозможно собрать никакой образ — всегда есть unfixed CVE в системе.
  • SARIF output → GitHub Code Scanning, findings в Security tab.

Trivy сканирует:

  1. OS packages (apk, deb, rpm).
  2. Language dependencies (Maven, npm, Go modules — то, что есть в image).
  3. Application dependencies через манифесты (pom.xml, build.gradle если включены в layer).
  4. Misconfigurations (Docker best practices) с --security-checks config.

Base image

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

Вариант 1: Eclipse Temurin Alpine

FROM eclipse-temurin:21-jre-alpine@sha256:abc123def456...

Eclipse Temurin — production-ready OpenJDK distribution. Alpine — минимальный Linux (~5 MB), меньше surface attack. JRE-only (не JDK) — нет javac, нет лишних утилит.

Вариант 2: Distroless

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

Distroless — образы Google без OS shell, без package manager, без curl. Только JRE + системные библиотеки. Атакёр после RCE не может apt install, не может wget — surface drastically reduced.

:nonroot tag — pre-configured non-root user (UID 65532).

Цена distroless — debugging сложнее (нет sh, нет cat). Для production это правильный trade-off.

Digest-pin

# ХОРОШО
FROM eclipse-temurin:21-jre-alpine@sha256:a1b2c3d4...

# СНОСНО — explicit version tag
FROM eclipse-temurin:21.0.5_11-jre-alpine

# ПЛОХО — drift возможен
FROM eclipse-temurin:21-jre-alpine

# КАТАСТРОФА — невоспроизводимо
FROM eclipse-temurin:latest
FROM openjdk:latest
FROM ubuntu:latest

:latest или mutable tags меняются — сборка из месячной давности commit даёт другой образ. Невозможно reproduce production-version, невозможно debug «работало вчера».

@sha256: digest — immutable. Pin to specific digest, обновляешь явно через PR.

Non-root user

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

FROM eclipse-temurin:21-jre-alpine@sha256:...

RUN addgroup -g 1000 app && adduser -D -u 1000 -G app app

WORKDIR /app
COPY --chown=app:app build/libs/app.jar /app/app.jar

USER 1000:1000

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Что даёт non-root:

  • CVE escape (sandbox break через JVM bug) → попадаешь в контейнер как app, не как root.
  • Нет доступа к privileged operations (mount, kernel modules).
  • К metadata service (AWS EC2 instance metadata, GCP metadata) часто привязан root access — non-root ограничивает.

Distroless :nonroot tag — это уже сделано.

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

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

readOnlyRootFilesystem: true — даже attacker внутри контейнера не может писать на disk. drop: ALL capabilities — нет даже CAP_NET_RAW.

HEALTHCHECK или K8s probes

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

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

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
    CMD wget --quiet --tries=1 --spider http://localhost:8081/actuator/health/liveness || exit 1

При unhealthy — Docker помечает контейнер, можно настроить auto-restart.

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

spec:
  containers:
    - name: app
      livenessProbe:
        httpGet:
          path: /actuator/health/liveness
          port: 8081
        initialDelaySeconds: 30
        periodSeconds: 10
      readinessProbe:
        httpGet:
          path: /actuator/health/readiness
          port: 8081
        initialDelaySeconds: 5
        periodSeconds: 5

Подробнее — Observability → health checks.

В K8s deployments — обычно достаточно probes, HEALTHCHECK в Dockerfile дублируется. Для docker-compose / standalone — HEALTHCHECK нужен.

Без него — зависший JVM (deadlock, native memory leak, GC overhead) не детектируется, traffic продолжает идти на dead pod.

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

USER root или отсутствие USER

R-SEC-IMG-X1:

FROM eclipse-temurin:21-jre-alpine
COPY app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
# нет USER — запуск от root

В контейнере процесс работает как root. RCE → root в контейнере → escape к host (через CVE в Docker / runc) → root на host. Catastrophic.

:latest base image

R-SEC-IMG-X2:

FROM openjdk:latest

Через месяц openjdk:latest — другой образ. Production build не воспроизводим. Невозможно roll-back, невозможно forensic analysis «что было в production вчера».

Always digest-pin или explicit version.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
USER root или отсутствие USERR-SEC-IMG-X1USER 1000:1000
:latest base imageR-SEC-IMG-X2digest-pin (@sha256:...)
Без Trivy в CIR-SEC-IMG-1scan после build, до push
Trivy без exit-code: 1R-SEC-IMG-1блокировать на HIGH+
Trivy без ignore-unfixedR-SEC-IMG-1unfixed — отдельная задача
Без HEALTHCHECK / probesR-SEC-IMG-4минимум K8s probes
JDK image для production (:21-jdk)R-SEC-IMG-2:21-jre (JRE-only)
Без securityContext в K8sR-SEC-IMG-3runAsNonRoot: true + capabilities drop
CMD без ENTRYPOINTR-SEC-IMG-2явный ENTRYPOINT

Куда дальше

  • Security → раздел 4. Container/image — нормативные формулировки.
  • SAST по коду — code-level vulnerabilities.
  • CVE в зависимостях — Java deps, не OS packages.
  • Секреты в коде и истории — не в Docker layers через ENV.
  • Реакция на findings — SLA per severity.
  • Observability → health checks — liveness vs readiness.
  • Graceful shutdown — Docker SIGTERM, K8s terminationGracePeriod.