Опирается на правила: 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-пакеты, Go-зависимости из бинаря.
  • HIGH/CRITICAL--exit-code 1. --ignore-unfixed — CVE без патча от upstream не блокируют сборку.
  • Base image для Go: gcr.io/distroless/static-debian12:nonroot или alpine:3.20 с @sha256: — статически слинкованный Go-бинарь не требует libc.
  • Digest-pin обязателен@sha256:.... Не :latest, не mutable-тег без digest в production.
  • Non-root user: в distroless:nonroot — UID 65532 встроен, для alpine — явный adduser; финальная строка USER 65532:65532.
  • HEALTHCHECK в Dockerfile или liveness/readiness probe в K8s — обязательно; без них зависший горутин-пул не детектируется.
  • Multi-stage build — единственный правильный паттерн: builder-stage с golang:1.23-alpine, финальный stage — distroless/static или чистый alpine.

Go-бинарь статически слинкован по умолчанию (CGO_DISABLED=1) — финальный image может быть FROM scratch или distroless/static без libc. Это сокращает attack surface радикальнее, чем в Java: нет JVM, нет JRE-пакетов, нет libc при scratch. Но контейнер всё равно включает OS-слой builder'а, если собрано неправильно.

Multi-stage Dockerfile

Сервис orders-service — типичный паттерн:

FROM golang:1.23-alpine3.20@sha256:<digest> AS builder

WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o orders-service ./cmd/orders

FROM gcr.io/distroless/static-debian12:nonroot@sha256:<digest>

COPY --from=builder /build/orders-service /orders-service

EXPOSE 8080
USER 65532:65532
ENTRYPOINT ["/orders-service"]

Ключевые детали:

  • CGO_ENABLED=0 — статическая линковка, нет зависимости от glibc.
  • -trimpath убирает абсолютные пути из отладочных символов (не утекают пути build-машины).
  • -ldflags="-s -w" — стрипит отладочные символы и таблицу символов: бинарь меньше, DWARF нет.
  • distroless/static не имеет shell, package manager, curl — RCE-атакёр не может установить toolchain.
  • USER 65532:65532 — UID встроенного nonroot-пользователя в distroless:nonroot.

Если нужен alpine (для healthcheck через wget или curl):

FROM alpine:3.20@sha256:<digest>

RUN addgroup -S app && adduser -S app -G app

COPY --from=builder /build/orders-service /orders-service

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD wget --quiet --tries=1 --spider http://localhost:8080/health/live || exit 1

USER app:app
EXPOSE 8080
ENTRYPOINT ["/orders-service"]

Trivy в CI

R-SEC-IMG-1: сканирование после docker build, до docker push.

# .github/workflows/build.yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Scan with Trivy
        run: |
          trivy image \
            --exit-code 1 \
            --severity HIGH,CRITICAL \
            --ignore-unfixed \
            --format sarif \
            --output trivy.sarif \
            orders-service:${{ github.sha }}

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

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

Параметры:

  • --exit-code 1 — найдено HIGH/CRITICAL → CI red, push не происходит.
  • --ignore-unfixed — CVE, для которых нет патча в upstream, не блокируют; они попадают в SARIF как информационные.
  • --format sarif + upload → GitHub Security tab, единый дашборд findings.

Trivy сканирует Go-образ на трёх уровнях:

  1. OS-пакеты (apk, deb) — то, что установлено в alpine/debian-слое.
  2. Go-зависимости, встроенные в бинарь — Trivy читает SBOM из go.mod/go.sum внутри image.
  3. Misconfigurations (--security-checks config) — USER root, :latest, ADD вместо COPY.

Дополнительный аргумент --dependency-tree полезен для отладки: показывает, какой модуль тянет уязвимую версию.

Base image и digest-pin

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

# distroless/static — оптимально для Go (нет libc, нет shell)
FROM gcr.io/distroless/static-debian12:nonroot@sha256:<digest>

# alpine — если нужен shell для healthcheck или debug
FROM alpine:3.20@sha256:<digest>

# golang builder — только для builder-stage, не финального
FROM golang:1.23-alpine3.20@sha256:<digest> AS builder

Получить digest:

docker pull gcr.io/distroless/static-debian12:nonroot
docker inspect gcr.io/distroless/static-debian12:nonroot --format '{{index .RepoDigests 0}}'
# gcr.io/distroless/static-debian12@sha256:...

Digest обновляется через Renovate — он создаёт авто-PR с новым @sha256: при выходе патча base image (R-SEC-DEP-2).

# Хорошо — digest-pin
FROM gcr.io/distroless/static-debian12:nonroot@sha256:a1b2c3...

# Допустимо — явный тег без digest (обновляется вручную)
FROM alpine:3.20.3

# Плохо — mutable тег, drift возможен
FROM alpine:3.20

# Запрещено
FROM alpine:latest
FROM golang:latest

Non-root user

R-SEC-IMG-3.

В distroless:nonroot non-root пользователь встроен, достаточно:

FROM gcr.io/distroless/static-debian12:nonroot@sha256:<digest>
COPY --from=builder /build/orders-service /orders-service
USER 65532:65532
ENTRYPOINT ["/orders-service"]

Для alpine — создаём пользователя явно:

FROM alpine:3.20@sha256:<digest>

RUN addgroup -S app && adduser -S -u 1000 app -G app

COPY --from=builder /build/product-service /product-service

USER 1000:1000
EXPOSE 8080
ENTRYPOINT ["/product-service"]

Числовые UID/GID (1000:1000) надёжнее строковых имён — они не зависят от /etc/passwd внутри образа.

В K8s добавляется securityContext:

spec:
  containers:
    - name: orders-service
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        readOnlyRootFilesystem: true
        allowPrivilegeEscalation: false
        capabilities:
          drop:
            - ALL

readOnlyRootFilesystem: true работает с Go без доработок — бинарь ничего не пишет на диск, если логирует через slog в stdout. Если сервис пишет временные файлы (например, os.CreateTemp), нужен отдельный emptyDir-том.

HEALTHCHECK и K8s probes

R-SEC-IMG-4.

Стандартный подход для chi-роутера — /health/live и /health/ready в отдельном хендлере:

// adapters/in/http/health.go
func RegisterHealthRoutes(r chi.Router) {
    r.Get("/health/live", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })
    r.Get("/health/ready", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })
}

HEALTHCHECK в Dockerfile через встроенный бинарь (без wget/curl в distroless):

FROM gcr.io/distroless/static-debian12:nonroot@sha256:<digest>
COPY --from=builder /build/orders-service /orders-service
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD ["/orders-service", "-healthcheck"]
USER 65532:65532
ENTRYPOINT ["/orders-service"]
// cmd/orders/main.go
func main() {
    if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
        resp, err := http.Get("http://localhost:8080/health/live")
        if err != nil || resp.StatusCode != http.StatusOK {
            os.Exit(1)
        }
        os.Exit(0)
    }
    // ... основной запуск
}

K8s probes для customer-service:

spec:
  containers:
    - name: customer-service
      livenessProbe:
        httpGet:
          path: /health/live
          port: 8080
        initialDelaySeconds: 10
        periodSeconds: 15
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /health/ready
          port: 8080
        initialDelaySeconds: 5
        periodSeconds: 5

В K8s достаточно probes — HEALTHCHECK дублируется и может быть опущен.

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

АнтипаттернПравилоЧто взамен
USER root или отсутствие USER в финальном stageR-SEC-IMG-X1USER 65532:65532 (distroless) или USER 1000:1000 (alpine)
:latest или mutable-тег base imageR-SEC-IMG-X2digest-pin @sha256:...
Финальный stage с golang:* вместо distroless/alpineR-SEC-IMG-2multi-stage: builder + минимальный финальный stage
Trivy не запущен в CIR-SEC-IMG-1trivy image после build, до push
Trivy без --exit-code 1R-SEC-IMG-1fail CI на HIGH/CRITICAL
Trivy без --ignore-unfixedR-SEC-IMG-1unfixed CVE — отдельная задача, не блокируют
Без HEALTHCHECK и без K8s probesR-SEC-IMG-4минимум liveness/readiness в K8s
CGO_ENABLED=1 в builder без статической линковкиR-SEC-IMG-2CGO_ENABLED=0 → distroless/static
Секреты через ENV в DockerfileR-SEC-SECRET-X1только через os.Getenv из K8s Secret / Vault

Куда дальше

  • Криптография в коде — crypto/rand, AES-GCM, bcrypt; Go-идиомы.
  • CVE в зависимостях — govulncheck, go.sum, Renovate.
  • Реакция на findings — SLA по severity, suppressions со сроком.
  • SAST по коду — gosec, golangci-lint, staticcheck; SARIF в GitHub.
  • Секреты в коде и истории — envconfig, Gitleaks, .env.example.