Опирается на правила: R-SEC-SAST-1R-SEC-SAST-4 и R-SEC-SAST-X1 из Security Style Guide → раздел 1. SAST по коду.

Важно знать

  • tsc --noEmit strict обязателен — strictNullChecks даёт null-safety на этапе компиляции, нет оверхеда на runtime.
  • eslint-plugin-security обязателен: injection через конкатенацию, path traversal, ReDoS, hardcoded credentials, eval/new Function.
  • semgrep с рулсетами p/typescript + p/nodejs — security-specific паттерны поверх ESLint.
  • HIGH/CRITICAL ломает сборку (eslint --max-warnings 0 для security-правил; || true в CI — запрещён).
  • MEDIUM — обязательный комментарий ревьюера; LOW — игнорим.
  • Suppressions — // eslint-disable-next-line security/<rule> -- justify: ... до: YYYY-MM-DD; без кода правила и даты не принимается.
  • SARIF подключается к GitHub Security tab через @microsoft/eslint-formatter-sarif + semgrep/Trivy нативно.

SAST — анализ кода без выполнения. В Node-стеке три слоя: компилятор (tsc), линтер (eslint + eslint-plugin-security) и семантический анализатор (semgrep). Без них SQL-инъекция или eval(userInput) проходит ревью «глазами» и всплывает на security audit через год.

tsc strict и null-safety

R-SEC-SAST-1: статанализ на каждом build — tsc --noEmit в strict.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "noImplicitAny": true,
    "noUncheckedIndexedAccess": true
  }
}

В CI — обязательным шагом до тестов:

# .github/workflows/ci.yml
- name: type-check
  run: npx tsc --noEmit

strictNullChecks — эквивалент NullAway для Java: компилятор отслеживает undefined | null статически. noUncheckedIndexedAccess — обращение к элементу массива/словаря возвращает T | undefined, что заставляет явно обрабатывать edge-case:

const orders: Order[] = await this.orderRepository.findByCustomer(customerId);

// Без noUncheckedIndexedAccess — потенциальный NPE
const first = orders[0];
first.totalAmount; // может упасть

// С noUncheckedIndexedAccess — компилятор требует guard
const first = orders[0];
if (first === undefined) return null;
first.totalAmount; // безопасно

При finding — compilation error, не warning. Код не собирается → не попадает в PR.

eslint + eslint-plugin-security

R-SEC-SAST-2: ESLint с typescript-eslint strict-пресетом и eslint-plugin-security.

// eslint.config.mjs
import tseslint from 'typescript-eslint';
import security from 'eslint-plugin-security';

export default tseslint.config(
  tseslint.configs.strictTypeChecked,
  security.configs.recommended,
  {
    rules: {
      'security/detect-object-injection': 'error',
      'security/detect-non-literal-fs-filename': 'error',
      'security/detect-non-literal-regexp': 'error',
      'security/detect-unsafe-regex': 'error',
      'security/detect-buffer-noassert': 'error',
      'security/detect-child-process': 'error',
      'security/detect-disable-mustache-escape': 'error',
      'security/detect-eval-with-expression': 'error',
      'security/detect-new-buffer': 'error',
      'security/detect-no-csrf-before-method-override': 'error',
      'security/detect-possible-timing-attacks': 'warn',
      'security/detect-pseudoRandomBytes': 'error',
    },
  },
);

CI — fail на любой security-warning:

npx eslint src/ --max-warnings 0 --format @microsoft/eslint-formatter-sarif \
  --output-file results.sarif

Что ловит eslint-plugin-security:

  • detect-object-injectionobj[userInput] без валидации ключа:
// Антипаттерн — произвольный ключ из запроса
const productField = req.query.field as string;
const value = product[productField]; // inject любое поле объекта

// Безопасно — whitelist допустимых полей
const ALLOWED_FIELDS = ['name', 'price', 'sku'] as const;
type ProductField = typeof ALLOWED_FIELDS[number];
const field = ALLOWED_FIELDS.find(f => f === req.query.field);
if (!field) throw new BadRequestException('Unknown field');
const value = product[field];
  • detect-non-literal-fs-filename — path traversal через fs.readFile(userInput):
// Антипаттерн — уязвимость path traversal
async getOrderAttachment(filename: string): Promise<Buffer> {
  return fs.promises.readFile(`/attachments/${filename}`); // ../../../etc/passwd
}

// Безопасно — нормализация и проверка prefix
async getOrderAttachment(filename: string): Promise<Buffer> {
  const base = path.resolve('/attachments');
  const target = path.resolve(base, path.basename(filename));
  if (!target.startsWith(base + path.sep)) {
    throw new ForbiddenException('Invalid attachment path');
  }
  return fs.promises.readFile(target);
}
  • detect-unsafe-regex — ReDoS через полиномиальный regex:
// Антипаттерн — (a+)+ на длинной строке → exponential backtracking
const EMAIL_UNSAFE = /^(([^<>()[\]]+(\.[^<>()[\]]+)*)|(".+"))@...$/;

// Безопасно — линейный regex или библиотека с защитой от ReDoS
import { isEmail } from 'class-validator'; // проверен на ReDoS
  • detect-eval-with-expressioneval(userInput) или new Function(userInput):
// Антипаттерн
const result = eval(req.body.expression);

// Безопасно — явный whitelist допустимых операций или DSL-парсер
  • detect-child-processexec/spawn с конкатенацией пользовательских данных:
// Антипаттерн — command injection
const { exec } = require('child_process');
exec(`convert ${req.query.file} output.pdf`); // ; rm -rf /

// Безопасно — spawn с массивом аргументов, без shell
import { execFile } from 'child_process';
execFile('convert', [sanitizedFilename, 'output.pdf']);

semgrep

R-SEC-SAST-2: semgrep с рулсетами p/typescript и p/nodejs покрывает паттерны, которые ESLint структурно не видит — например, небезопасный JWT decode, проброс заголовков без strip, NoSQL injection.

# .github/workflows/ci.yml
- name: semgrep
  uses: returntocorp/semgrep-action@v1
  with:
    config: >-
      p/typescript
      p/nodejs
      p/secrets
    generateSarif: "1"
  env:
    SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}

Локально:

semgrep --config p/typescript --config p/nodejs src/ --sarif -o semgrep.sarif

Примеры паттернов semgrep поверх ESLint:

// semgrep обнаружит: jwt.decode() без verify — критично
import jwt from 'jsonwebtoken';
const payload = jwt.decode(token); // нет проверки подписи!
// Правильно
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

// semgrep обнаружит: MongoDB NoSQL injection через req.body напрямую
this.customerModel.find(req.body.filter); // inject { $where: 'sleep(5000)' }
// Правильно — типизированный DTO через class-validator
async findCustomers(@Body() dto: FindCustomersDto) {
  this.customerModel.find({ status: dto.status });
}

SARIF из semgrep и ESLint загружается в GitHub Security tab через actions/upload-artifact + Code Scanning:

- name: upload-sarif
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: semgrep.sarif

Severity → действие

R-SEC-SAST-3: реакция per-severity.

Severityeslint-plugin-security / semgrepДействие
CRITICALinjection, eval, hardcoded credentialsСборка падает
HIGHpath traversal, command injection, unsafe regexСборка падает
MEDIUMtiming attack, слабая криптаОбязательный комментарий ревьюера
LOWстилистические паттерныИгнорим

ESLint-правила, где severity настраивается как "error" — fail, как "warn" — попадают в --max-warnings 0 счётчик. Security-правила уровня HIGH+ должны быть "error".

|| true в CI-команде — запрещён: превращает SAST в дашборд, который никто не смотрит.

Suppressions со сроком

R-SEC-SAST-4: каждое подавление — код правила, обоснование, дата.

// eslint — inline suppression
// eslint-disable-next-line security/detect-object-injection -- justify: поле orderStatus из ALLOWED_ORDER_STATUSES enum, не из запроса. До: 2026-12-31
const value = order[statusField];
# semgrep — nosemgrep в комментарии
result = jwt.verify(token, key, { algorithms: ['RS256'] }); # nosemgrep: javascript.jsonwebtoken.security.jwt-decode-without-verify.jwt-decode-without-verify -- justify: verify вызывается на строку выше в контексте refresh-токена. До: 2026-09-30

Глобальные overrides в eslint.config.mjs для false-positive на generated code:

// eslint.config.mjs
{
  files: ['src/generated/**'],
  rules: {
    'security/detect-object-injection': 'off',
  },
},

Раз в квартал — скрипт ищет просроченные suppressions:

grep -rn "До: 20" src/ | awk -F'До: ' '{print $2}' | \
  while read d; do
    [[ "$d" < "$(date +%Y-%m-%d)" ]] && echo "EXPIRED: $d"
  done

Где SAST не помогает

SAST имеет структурные ограничения — не понимает runtime-значения, даёт false-positive на framework-specific паттерны (NestJS DI, Passport guards), не покрывает бизнес-логику (ABAC bypass, race condition в Order.confirm()).

SAST — первый слой, не единственный. Дополняется:

  • CVE в зависимостях (R-SEC-DEP-*) — npm audit / osv-scanner.
  • Секреты в коде (R-SEC-SECRET-*) — Gitleaks.
  • Container-уязвимости (R-SEC-IMG-*) — Trivy.
  • Manual code review — для бизнес-логики.

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

АнтипаттернПравилоЧто взамен
// eslint-disable без кода правила и justification ≥ 30 символовR-SEC-SAST-X1// eslint-disable-next-line security/<rule> -- justify: ...
nosemgrep без rule-id и причиныR-SEC-SAST-X1# nosemgrep: <rule-id> -- justify: ...
Suppression без даты до: YYYY-MM-DDR-SEC-SAST-4дата пересмотра обязательна
|| true в CI-команде eslint / semgrepR-SEC-SAST-3сборка падает на HIGH+
MEDIUM-finding без комментария ревьюераR-SEC-SAST-3обязательный комментарий в PR
tsc без strict: trueR-SEC-SAST-1strict + noUncheckedIndexedAccess
ESLint без typescript-eslint strict-пресетаR-SEC-SAST-2tseslint.configs.strictTypeChecked
Без SARIF output в GitHub Security tabR-SEC-SAST-2@microsoft/eslint-formatter-sarif
eval(userInput) / new Function(userInput)R-SEC-SAST-2whitelist операций, DSL-парсер
child_process.exec с конкатенацией строкR-SEC-SAST-2execFile с массивом аргументов

Куда дальше

  • Security → раздел 1. SAST по коду — нормативные формулировки.
  • CVE в зависимостях — npm audit / osv-scanner, следующий слой.
  • Секреты в коде и истории — Gitleaks + husky.
  • Container/image-уязвимости — Trivy, USER node, digest-pinning.
  • Реакция на findings — Severity → SLA, SARIF, квартальный отчёт.
  • Криптография — argon2, crypto.randomBytes, AES-GCM.