Опирается на правила:
R-SEC-IMG-1…R-SEC-IMG-4иR-SEC-IMG-X1…R-SEC-IMG-X2из Security Style Guide → раздел 4. Container/image-уязвимости.
Важно знать
- Trivy обязателен для всех Docker-образов — сканирует base image, OS-пакеты, npm-зависимости из
package.json.- HIGH/CRITICAL →
exit-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 start—npmглотает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-образе:
- OS packages (deb/rpm/apk — зависит от дистрибутива base image).
- npm зависимости — через
package-lock.jsonвнутри образа (Trivy читает lock-файл из layer). - 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 нет npm — CMD указывает путь к файлу напрямую, 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 посылает SIGTERM → npm его получает, но не пробрасывает в 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 или отсутствие USER | R-SEC-IMG-X1 | USER node (в node-образах) / USER 1000:1000 |
:latest base image (node:latest) | R-SEC-IMG-X2 | digest-pin (@sha256:...) |
| Без Trivy в CI | R-SEC-IMG-1 | scan после docker build, до docker push |
Trivy без exit-code: 1 | R-SEC-IMG-1 | блокировать CI на HIGH/CRITICAL |
Trivy без ignore-unfixed: true | R-SEC-IMG-1 | unfixed CVE — отдельная задача |
CMD ["npm", "start"] / CMD ["npm", "run", "start:prod"] | R-SEC-IMG-4 | CMD ["node", "dist/main.js"] |
| Без health endpoint / probes | R-SEC-IMG-4 | @nestjs/terminus + K8s probes |
devDependencies в production-образе | R-SEC-IMG-2 | multi-stage build, npm ci --omit=dev |
Без --chown при COPY | R-SEC-IMG-3 | COPY --chown=node:node |
Без securityContext в K8s | R-SEC-IMG-3 | runAsNonRoot: 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.