Опирается на правила: R-SEC-SECRET-1R-SEC-SECRET-3 и R-SEC-SECRET-X1R-SEC-SECRET-X2 из Security Style Guide → раздел 3. Секреты в коде и истории.

Важно знать

  • Gitleaks в pre-commit + CI — два слоя: pre-commit ловит до push, CI страховочно.
  • Full history scan раз в неделю — секрет может всплыть задним числом (старый коммит, новые правила).
  • Pre-commit hook через husky"prepare": "husky" в package.json, устанавливается одним npm ci, не ручной инструкцией в README.
  • Утечка → rotate сначала — в течение часа; удаление из истории опционально и не отменяет компрометацию.
  • GitHub уже проиндексировал — AWS-боты эксплуатируют ключ за минуты, git rebase не помогает.
  • Секреты в src/ — запрещены; читаются через ConfigService + @nestjs/config c validationSchema.
  • .env в git — запрещён даже «для примера»; используй .env.example без значений.
  • git commit --no-verify — обходит pre-commit; CI — единственный уровень, который нельзя обойти.

Один закоммиченный пароль или API-ключ — инцидент даже если удалить через минуту. GitHub и специализированные сканеры индексируют репозитории в реальном времени; AWS-ключи эксплуатируются в первые секунды после появления в публичном репо. UCP формулирует двухслойную защиту: предотвращение через pre-commit + детекция через CI + немедленная rotation при обнаружении.

Gitleaks в pre-commit

R-SEC-SECRET-1 — первый слой, до push.

.gitleaks.toml в корне репо:

title = "order-service gitleaks config"

[allowlist]
description = "Разрешённые паттерны"
paths = [
    '''\.example$''',
    '''^test/.*\.fixture\.json$''',
    '''README\.md''',
]

[[rules]]
id = "generic-api-key"
description = "Generic API Key"
regex = '''(?i)(?:api[_-]?key|apikey|api[_-]?token)['"\s:=]+['"]([0-9a-zA-Z\-_]{20,})'''
keywords = ["api_key", "apikey", "api-key"]

[[rules]]
id = "aws-access-key"
description = "AWS Access Key ID"
regex = '''AKIA[0-9A-Z]{16}'''

[[rules]]
id = "private-key"
description = "Private SSH/TLS key"
regex = '''-----BEGIN (?:RSA |OPENSSH |DSA |EC |PGP )?PRIVATE KEY( BLOCK)?-----'''

[[rules]]
id = "jwt"
description = "JWT token"
regex = '''eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+'''

Pre-commit hook через husky:

npm install --save-dev husky
npx husky init

.husky/pre-commit:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

docker run --rm -v "$(pwd):/repo" zricethezav/gitleaks:latest detect \
    --source=/repo \
    --config=/repo/.gitleaks.toml \
    --staged

--staged — сканирует только staged changes (то, что войдёт в коммит). При finding — exit 1, коммит блокируется:

Finding:     AKIAIOSFODNN7EXAMPLE
Secret:      AKIAIOSFODNN7EXAMPLE
RuleID:      aws-access-key
File:        src/config/payment.config.ts
Line:        12

Pre-commit hook автоматически

R-SEC-SECRET-2 — hook коммитится в репо, устанавливается одним npm ci.

package.json:

{
  "scripts": {
    "prepare": "husky"
  },
  "devDependencies": {
    "husky": "^9.1.0"
  }
}

После git clone + npm ci — hook установлен у всех разработчиков автоматически. Без husky README пишет «установи hook вручную», большинство разработчиков пропускают этот шаг.

CI как страховка

R-SEC-SECRET-1git commit --no-verify обходит pre-commit; CI неминуем.

GitHub Actions:

name: Gitleaks
on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: '0 3 * * 1'  # weekly full history scan

jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}

На PR — сканирует diff. На push в main — diff. Раз в неделю — full history: secret мог быть закоммичен полгода назад, когда правила были мягче, теперь pattern его ловит.

Секреты через ConfigService

R-SEC-SECRET-X1 — секреты в src/ запрещены. В NestJS единственный легитимный путь — @nestjs/config с валидацией схемы.

app.module.ts:

import { ConfigModule } from '@nestjs/config';
import * as Joi from 'joi';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        DB_PASSWORD:           Joi.string().required(),
        PAYMENT_CLIENT_SECRET: Joi.string().required(),
        SBER_API_KEY:          Joi.string().required(),
        NODE_ENV:              Joi.string().valid('development', 'production', 'test').required(),
      }),
    }),
  ],
})
export class AppModule {}

order.service.ts — инжект через DI, значение никогда не хардкодится:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class OrderService {
  constructor(private readonly config: ConfigService) {}

  async submitPayment(orderId: string): Promise<void> {
    const apiKey = this.config.getOrThrow<string>('SBER_API_KEY');
    // используется только здесь, не логируется
  }
}

getOrThrow — бросает исключение при старте, если переменная отсутствует, а не падает в рантайме при первом запросе. Приложение не запустится с неполной конфигурацией.

Локально — .env:

# .env (в .gitignore)
DB_PASSWORD=devpassword123
PAYMENT_CLIENT_SECRET=test-secret
SBER_API_KEY=test-sber-key
NODE_ENV=development

В production — секреты через runtime: Kubernetes Secret, Vault Agent Injector, AWS Secrets Manager или GCP Secret Manager; в контейнер попадают как env vars, не хранятся в образе.

Если секрет утёк — rotate first

R-SEC-SECRET-3 — порядок действий.

Сценарий: gitleaks нашёл закоммиченный SBER_API_KEY в customer.config.ts.

Шаг 1 (в течение часа): rotate.

  • Войти в панель Sber Business, деактивировать этот ключ.
  • Сгенерировать новый, обновить env vars в CI и кластере.
  • Уведомить команду безопасности.

Шаг 2 (опционально): удаление из истории.

  • git filter-repo или bfg-repo-cleaner — переписать историю без секрета.
  • Force push в main, все разработчики делают git clone заново.

Почему именно в таком порядке: GitHub и специализированные боты индексируют репозиторий в реальном времени. Время от push до эксплуатации AWS-ключа — секунды. git rebase через час бесполезен: секрет уже у злоумышленника. bfg-repo-cleaner нужен только для compliance — доказать аудитору, что в репо больше нет секрета, — но не возвращает скомпрометированный ключ.

.env в git

R-SEC-SECRET-X2 — запрещён даже с пометкой «example».

# НЕЛЬЗЯ — .env в git
DB_PASSWORD=devpassword123
SBER_API_KEY=test-key-please-replace

Разработчик не заметит и положит prod-значение вместо заглушки — утечка. Боты скрейпят .env-файлы так же активно, как и исходный код.

Правильно:

# .env.example (закоммитен, без значений)
DB_PASSWORD=
PAYMENT_CLIENT_SECRET=
SBER_API_KEY=
NODE_ENV=
# .env (в .gitignore, локальный)
DB_PASSWORD=devpassword123
PAYMENT_CLIENT_SECRET=test-secret
SBER_API_KEY=test-sber-key
NODE_ENV=development

.gitignore:

.env
.env.local
.env.*.local
*.pem
*.key

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

АнтипаттернПравилоЧто взамен
Секрет в src/ или hardcoded в ConfigModuleR-SEC-SECRET-X1ConfigService.getOrThrow + env var
.env в git (даже с заглушками)R-SEC-SECRET-X2.env.example без значений
Секрет в переменной ENV в DockerfileR-SEC-SECRET-X1env через runtime (k8s Secret / Vault)
git rebase / bfg без rotationR-SEC-SECRET-3rotate first, история — опционально
Pre-commit hook в README, не автоматическиR-SEC-SECRET-2husky + "prepare": "husky"
Gitleaks только в CI, без pre-commitR-SEC-SECRET-1оба слоя
Скан только diff, без weekly full historyR-SEC-SECRET-1schedule: cron в CI
git commit --no-verify как нормаR-SEC-SECRET-2CI как неминуемый второй слой

Куда дальше

  • Container/image-уязвимости — секреты не должны попасть в слои Docker-образа.
  • Криптография в коде — hardcoded ключи/IV как частный случай secrets-антипаттерна.
  • CVE в зависимостях — другой класс уязвимостей: библиотеки, не секреты.
  • Реакция на findings — SLA для утечки секрета: hotfix ≤ 1 час.
  • SAST по коду — eslint-plugin-security ловит detect-non-literal-require и hardcoded credentials.