Опирается на правила: 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-пакеты, npm-зависимости из package.json.
  • HIGH/CRITICALexit-code: 1. ignore-unfixed: true — CVE без патча от upstream не блокируют, идут в отдельную задачу.
  • Base image: node:22-slim или gcr.io/distroless/nodejs22-debian12. Никогда :latest.
  • Digest-pin обязателен — @sha256:.... Без него сборка из прошлого коммита даёт другой образ.
  • Non-root: в node-образах есть готовый пользователь — USER node (UID 1000). Для distroless — USER 1000:1000.
  • CMD ["node", "dist/main.js"], не npm startnpm глотает SIGTERM, контейнер ждёт таймаута и убивается SIGKILL. Graceful shutdown ломается.
  • @nestjs/terminus для health endpoint — HEALTHCHECK или K8s liveness/readiness пробы обязательны.

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

Trivy в CI

R-SEC-IMG-1: Trivy запускается после 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.
  • ignore-unfixed: true — CVE, для которых upstream не выпустил патч, не блокируют; иначе собрать образ невозможно — unfixed CVE есть всегда.
  • SARIF → GitHub Code Scanning — findings в Security tab без отдельной инфраструктуры (R-SEC-FIND-3).

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

  1. OS packages (deb/rpm/apk — зависит от дистрибутива base image).
  2. npm зависимости — через package-lock.json внутри образа (Trivy читает lock-файл из layer).
  3. Misconfigurations — Docker best practices с --security-checks config (можно добавить флагом).

Base image

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

Вариант 1: node:22-slim

FROM node:22-slim@sha256:a1b2c3d4...

node:22-slim — официальный образ на Debian slim без лишних инструментов разработки. Минимальная поверхность атаки при сохранении /bin/sh для отладки в staging-окружениях.

В node-образах уже есть системный пользователь node (UID 1000, GID 1000) — не нужно создавать вручную.

Вариант 2: distroless/nodejs22

FROM gcr.io/distroless/nodejs22-debian12@sha256:...

Distroless — образы Google без shell, без package manager, без curl. Только Node runtime + минимальные системные библиотеки. После RCE атакующий не может запустить bash, не может apt install — поверхность атаки сокращается кардинально.

Для distroless используй USER 1000:1000 (без предварительного adduser — системный пользователь уже в образе).

Digest-pin

# ПРЕДПОЧТИТЕЛЬНО
FROM node:22-slim@sha256:a1b2c3d4e5f6...

# ДОПУСТИМО — explicit version tag
FROM node:22.13.1-slim

# НЕЖЕЛАТЕЛЬНО — тег может переехать
FROM node:22-slim

# ЗАПРЕЩЕНО — невоспроизводимо
FROM node:latest
FROM node:current

:latest и mutable tags меняются — сборка двухнедельной давности коммита даёт другой образ. Невозможно воспроизвести production-build, невозможно отладить «работало в пятницу».

@sha256: digest — immutable. Обновляй явно через PR (Renovate умеет автоматически обновлять digest-pinned образы).

Многоступенчатая сборка

В Node-образах необходима многоступенчатая сборка (multi-stage build): node_modules для сборки содержат devDependencies (TypeScript, ts-node, jest) — они не нужны в production-образе и увеличивают поверхность атаки.

FROM node:22-slim@sha256:a1b2c3d4... AS builder

WORKDIR /build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim@sha256:a1b2c3d4... AS runtime

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /build/dist ./dist

USER node

EXPOSE 3000
CMD ["node", "dist/main.js"]

Ключевые точки:

  • npm ci --omit=dev во втором stage — только production-зависимости. devDependencies не попадают в финальный образ.
  • COPY --from=builder — берём скомпилированный dist/ из первого stage.
  • CMD ["node", "dist/main.js"], не npm run start:prod — важно (см. ниже).

Non-root user

R-SEC-IMG-3: процесс не должен работать от root.

В официальных node-образах есть готовый пользователь node (UID 1000):

FROM node:22-slim@sha256:...

WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node dist/ ./dist/

USER node

EXPOSE 3000
CMD ["node", "dist/main.js"]

--chown=node:node при COPY — файлы принадлежат пользователю node. Если WORKDIR создаётся автоматически Docker'ом, он принадлежит root; COPY --chown исправляет это.

Для distroless:

FROM gcr.io/distroless/nodejs22-debian12@sha256:...

WORKDIR /app
COPY dist/ ./dist/
COPY node_modules/ ./node_modules/

USER 1000:1000

CMD ["dist/main.js"]

В distroless нет npmCMD указывает путь к файлу напрямую, Node runtime — entrypoint образа.

В K8s добавляй securityContext:

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

readOnlyRootFilesystem: true — записи в filesystem нет даже после RCE. drop: ALL capabilities — нет CAP_NET_RAW и прочих привилегий.

Исключение: NestJS при readOnlyRootFilesystem: true может потребовать writable-директории для tmp (/tmp через emptyDir volume). Проверяй в staging.

CMD node, не npm start

R-SEC-IMG-4 включает требование к корректному graceful shutdown. Node-специфичная ловушка: npm start создаёт дочерний процесс для Node, а сам процесс npm не пробрасывает сигналы.

# ПРАВИЛЬНО — SIGTERM доходит до Node
CMD ["node", "dist/main.js"]

# НЕПРАВИЛЬНО — npm не пробрасывает SIGTERM
CMD ["npm", "run", "start:prod"]
CMD ["npm", "start"]

С npm start: Docker посылает SIGTERMnpm его получает, но не пробрасывает в node → Node продолжает работу → Docker ждёт stopTimeout (по умолчанию 10с) → посылает SIGKILL → приложение убивается без graceful shutdown. Незакоммиченные транзакции, недоотправленные ответы, незакрытые соединения с БД.

NestJS enableShutdownHooks() в CustomerServiceApp срабатывает только если SIGTERM доходит до процесса Node.

Health probes через @nestjs/terminus

R-SEC-IMG-4: runtime-проблемы должны детектироваться.

import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
  ) {}

  @Get('liveness')
  @HealthCheck()
  liveness() {
    return this.health.check([]);
  }

  @Get('readiness')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database'),
    ]);
  }
}

Liveness — «процесс жив». Не проверяет внешние зависимости. Если упал — K8s перезапускает pod.

Readiness — «сервис готов принимать трафик». Проверяет БД, Redis и т.д. Если упал — K8s убирает pod из балансировки, не перезапускает.

K8s probes:

livenessProbe:
  httpGet:
    path: /health/liveness
    port: 3000
  initialDelaySeconds: 15
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /health/readiness
    port: 3000
  initialDelaySeconds: 5
  periodSeconds: 5
  failureThreshold: 3

HEALTHCHECK в Dockerfile для docker-compose / standalone:

HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health/liveness', r => process.exit(r.statusCode === 200 ? 0 : 1))"

Используем node -e вместо curl/wget — в node:22-slim curl нет по умолчанию, а Node — есть всегда.

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

АнтипаттернПравилоЧто взамен
USER root или отсутствие USERR-SEC-IMG-X1USER node (в node-образах) / USER 1000:1000
:latest base image (node:latest)R-SEC-IMG-X2digest-pin (@sha256:...)
Без Trivy в CIR-SEC-IMG-1scan после docker build, до docker push
Trivy без exit-code: 1R-SEC-IMG-1блокировать CI на HIGH/CRITICAL
Trivy без ignore-unfixed: trueR-SEC-IMG-1unfixed CVE — отдельная задача
CMD ["npm", "start"] / CMD ["npm", "run", "start:prod"]R-SEC-IMG-4CMD ["node", "dist/main.js"]
Без health endpoint / probesR-SEC-IMG-4@nestjs/terminus + K8s probes
devDependencies в production-образеR-SEC-IMG-2multi-stage build, npm ci --omit=dev
Без --chown при COPYR-SEC-IMG-3COPY --chown=node:node
Без securityContext в K8sR-SEC-IMG-3runAsNonRoot: true + drop: ALL

Куда дальше

  • SAST по коду — eslint-plugin-security, semgrep для NestJS.
  • CVE в зависимостях — npm audit, osv-scanner для npm-пакетов.
  • Секреты в коде и истории — Gitleaks, husky; не в ENV Dockerfile.
  • Реакция на findings — SLA по severity, SARIF в Security tab.
  • Криптография — argon2, crypto.randomBytes, AES-GCM в Node.