Опирается на правила:
R-SEC-SAST-1…R-SEC-SAST-4иR-SEC-SAST-X1из Security Style Guide → раздел 1. SAST по коду.
Важно знать
tsc --noEmitstrict обязателен —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-injection—obj[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-expression—eval(userInput)илиnew Function(userInput):
// Антипаттерн
const result = eval(req.body.expression);
// Безопасно — явный whitelist допустимых операций или DSL-парсер
detect-child-process—exec/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.
| Severity | eslint-plugin-security / semgrep | Действие |
|---|---|---|
| CRITICAL | injection, eval, hardcoded credentials | Сборка падает |
| HIGH | path traversal, command injection, unsafe regex | Сборка падает |
| MEDIUM | timing 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-DD | R-SEC-SAST-4 | дата пересмотра обязательна |
|| true в CI-команде eslint / semgrep | R-SEC-SAST-3 | сборка падает на HIGH+ |
| MEDIUM-finding без комментария ревьюера | R-SEC-SAST-3 | обязательный комментарий в PR |
tsc без strict: true | R-SEC-SAST-1 | strict + noUncheckedIndexedAccess |
ESLint без typescript-eslint strict-пресета | R-SEC-SAST-2 | tseslint.configs.strictTypeChecked |
| Без SARIF output в GitHub Security tab | R-SEC-SAST-2 | @microsoft/eslint-formatter-sarif |
eval(userInput) / new Function(userInput) | R-SEC-SAST-2 | whitelist операций, DSL-парсер |
child_process.exec с конкатенацией строк | R-SEC-SAST-2 | execFile с массивом аргументов |
Куда дальше
- 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.