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

Важно знать

  • ruff (S-набор) + mypy --strict обязательны на каждом build и PR — ловят на уровне линтера и type checker.
  • bandit обязателен для production-кода — AST-сканер, находит SQLi, command injection, eval/exec, weak crypto, hardcoded passwords.
  • semgrep с security-rulesets — дополняет bandit семантическими паттернами, которые bandit пропускает.
  • HIGH/CRITICAL ломает сборку (--exit-zero запрещён). MEDIUM — обязательный комментарий ревьюера. LOW — игнорим.
  • # nosec без кода и justification ≥ 30 символов — запрещён.
  • SARIF из bandit и semgrep подключается к GitHub Code Scanning — единый дашборд без отдельной инфры.
  • SAST — единственная защита от классов уязвимостей, которые code review «глазами» пропускает.

SAST (Static Application Security Testing) — анализ кода без выполнения. UCP формулирует минимальный mandatory-набор для Python: быстрый слой (ruff/mypy на каждом build) + глубокий слой (bandit+semgrep на PR). Без них SQL-инъекция или небезопасный subprocess.call(shell=True) проходит ревью — security audit находит через год.

Быстрый слой: ruff S-набор + mypy

R-SEC-SAST-1: ruff с S-правилами (bandit-derived) и mypy запускаются на каждом build и PR — быстрая обратная связь ещё до commit.

# pyproject.toml
[tool.ruff]
select = [
    "S",    # bandit-derived security rules
    "E", "W", "F", "I",
]
ignore = []

[tool.mypy]
strict = true
plugins = ["pydantic.mypy"]

ruff S-набор ловит (без запуска кода):

  • S101assert в production-коде (убирается оптимизатором).
  • S105 / S106 — hardcoded password в строке или функции.
  • S108 — небезопасный /tmp без tempfile.
  • S301pickle.loads без проверки источника.
  • S324hashlib.md5/sha1 без usedforsecurity=False.
  • S501requests без верификации TLS (verify=False).
  • S608 — SQL через конкатенацию строк.

mypy --strict — null-safety на уровне типов. FastAPI + Pydantic с аннотациями покрывают большую часть, но явные Optional без проверки mypy находит.

ruff check src/
mypy src/ --strict

Глубокий слой: bandit

R-SEC-SAST-2: bandit — AST-сканер, анализирует семантику кода, не только паттерны строк.

# pyproject.toml
[tool.bandit]
targets = ["src"]
severity = "medium"
confidence = "medium"
recursive = true
skips = []

Запуск с SARIF:

bandit -r src/ \
  -f sarif \
  -o bandit-results.sarif \
  --severity-level high \
  --exit-zero  # НЕ использовать — см. ниже

--exit-zero запрещён (R-SEC-SAST-3). Правильный вызов:

bandit -r src/ -f sarif -o bandit-results.sarif --severity-level high
# exit code != 0 при HIGH/CRITICAL → CI падает

Что ловит bandit:

  • B601paramiko.exec_command(userInput) — command injection через SSH.
  • B608f"SELECT * FROM orders WHERE id = {order_id}" — SQL injection.
  • B102 / B603os.system(cmd) / subprocess.call(cmd, shell=True) — shell injection.
  • B301pickle.loads(data) — небезопасная десериализация.
  • B105password = "sber_secret_2024" — hardcoded credential.
  • B411xmlrpc — устаревший и небезопасный протокол.
  • B324hashlib.sha1(data) — weak hash для security (cross-ref R-SEC-CRYPTO-1).

Пример уязвимого кода в доменном контексте:

# B608 — SQLi через конкатенацию
async def get_order(order_id: str, db: AsyncSession) -> Order:
    result = await db.execute(
        text(f"SELECT * FROM orders WHERE id = '{order_id}'")
    )
    return result.scalar_one()

Правильно — параметризованный запрос:

async def get_order(order_id: UUID, db: AsyncSession) -> Order:
    result = await db.execute(
        select(OrderRecord).where(OrderRecord.id == order_id)
    )
    return result.scalar_one()
# B602 — shell injection через subprocess
import subprocess

def export_customer_report(customer_id: str) -> bytes:
    output = subprocess.check_output(
        f"generate-report --customer {customer_id}", shell=True
    )
    return output

Правильно — список аргументов, не строка:

def export_customer_report(customer_id: UUID) -> bytes:
    output = subprocess.check_output(
        ["generate-report", "--customer", str(customer_id)],
        shell=False,
    )
    return output

Глубокий слой: semgrep

R-SEC-SAST-2: semgrep дополняет bandit семантическими паттернами — находит уязвимости в многоступенчатых цепочках вызовов, которые bandit не видит.

# .semgrep.yml в CI
rules:
  - id: fastapi-unvalidated-path-param
    patterns:
      - pattern: |
          @app.get("...")
          async def $FUNC(..., $PARAM: str, ...):
              ...
              open($PARAM, ...)
    message: "Path traversal через незащищённый str-параметр"
    severity: ERROR
    languages: [python]

Готовые security-rulesets semgrep:

semgrep --config=p/python \
        --config=p/owasp-top-ten \
        --sarif \
        --output=semgrep-results.sarif \
        src/

Паттерны, которые semgrep находит, а bandit нет:

  • Передача пользовательского ввода через несколько функций до eval.
  • FastAPI endpoint, который передаёт query param в os.path.join без очистки.
  • Pydantic-модель с полем secret, которое попадает в логи.
# path traversal — semgrep найдёт через data flow
from fastapi import APIRouter
from pathlib import Path

router = APIRouter()

REPORTS_DIR = Path("/var/reports")

@router.get("/products/{product_id}/report")
async def get_product_report(product_id: str) -> bytes:
    report_path = REPORTS_DIR / product_id   # traversal: ../../etc/passwd
    return report_path.read_bytes()

Правильно — валидация через Pydantic + resolve():

from uuid import UUID
from fastapi import APIRouter, HTTPException
from pathlib import Path

router = APIRouter()

REPORTS_DIR = Path("/var/reports").resolve()

@router.get("/products/{product_id}/report")
async def get_product_report(product_id: UUID) -> bytes:
    report_path = (REPORTS_DIR / str(product_id)).resolve()
    if not report_path.is_relative_to(REPORTS_DIR):
        raise HTTPException(status_code=403)
    return report_path.read_bytes()

UUID как тип параметра — FastAPI валидирует формат автоматически через Pydantic, traversal невозможен.

Severity → действие

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

Severitybandit/semgrepruff/mypyДействие
CRITICALHIGH-confidence securityerrorСборка падает
HIGHHIGH severityerrorСборка падает
MEDIUMMEDIUM severitywarnОбязательный комментарий ревьюера
LOWLOW severityИгнорим

CI пример (GitHub Actions):

- name: bandit SAST
  run: |
    bandit -r src/ --severity-level high --confidence-level high
  # exit code != 0 → step fails → job fails

- name: semgrep SAST
  run: |
    semgrep --config=p/python --config=p/owasp-top-ten --error src/
  # --error: exit 1 при любом finding

Без явного fail CI остаётся зелёным с критической уязвимостью в SARIF. R-SEC-1: сборка падает на security-finding — не дашборд, а блокировщик.

Suppressions со сроком

R-SEC-SAST-4: # nosec только с кодом правила и justification.

import hashlib

def product_cache_key(product_id: UUID, variant: str) -> str:
    data = f"{product_id}:{variant}".encode()
    # nosec B324  # justify: non-security cache key, SHA-1 достаточен для дедупликации; не пароль — до: 2026-12-31
    return hashlib.sha1(data).hexdigest()

Правила:

  • # nosec BXXX — код правила обязателен.
  • # justify: ... — причина исключения ≥ 30 символов.
  • до: YYYY-MM-DD — срок пересмотра. Без даты не принимается.

Глобальные исключения — в pyproject.toml, не через # nosec везде:

[tool.bandit]
skips = ["B101"]  # assert: отключён только если pytest-only code в тестах

Но и глобальный skips требует комментария в pyproject.toml — иначе через полгода неясно, зачем отключено.

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

grep -rn "nosec" src/ | grep "до:" | \
  python -c "
import sys, re, datetime
today = datetime.date.today()
for line in sys.stdin:
    m = re.search(r'до: (\d{4}-\d{2}-\d{2})', line)
    if m and datetime.date.fromisoformat(m.group(1)) < today:
        print('ПРОСРОЧЕНО:', line.strip())
"

SARIF → GitHub Code Scanning

R-SEC-FIND-3: оба инструмента пишут SARIF, результаты видны в GitHub Security tab.

# .github/workflows/security.yml
- name: Upload SARIF — bandit
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: bandit-results.sarif
    category: bandit

- name: Upload SARIF — semgrep
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: semgrep-results.sarif
    category: semgrep

После подключения: все findings aggreagated в Security → Code scanning alerts. Baseline (R-SEC-4): на release сравнивается с предыдущим состоянием — блокируются только новые findings, старый долг — отдельной задачей.

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

bandit и semgrep имеют ограничения:

  • Не видят runtime-значения (переменная передаётся через несколько слоёв — semgrep видит частично, bandit нет).
  • False positives на framework-specific patterns (FastAPI dependency injection иногда интерпретируется как unvalidated input).
  • Не покрывают бизнес-логику (ABAC bypass, race conditions, IDOR).

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

  • CVE scanning (CVE в зависимостях) — pip-audit/Trivy.
  • Secrets scanning (Секреты в коде и истории) — Gitleaks.
  • Container scanning (Container/image-уязвимости) — Trivy.
  • Manual code review — для business logic.

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

АнтипаттернПравилоЧто взамен
# nosec без кода правила (BXXX)R-SEC-SAST-X1# nosec B608 с явным кодом
# nosec без justification ≥ 30 символовR-SEC-SAST-X1# justify: ... ≥ 30 символов
Suppression без срока до:R-SEC-SAST-4дата пересмотра обязательна
bandit --exit-zero в CIR-SEC-SAST-3убрать --exit-zero, сборка падает на HIGH+
semgrep без --errorR-SEC-SAST-3флаг --error обязателен
MEDIUM findings без комментария ревьюераR-SEC-SAST-3обязательный комментарий в PR
ruff без S-набораR-SEC-SAST-1select = ["S", ...] в pyproject.toml
Без SARIF outputR-SEC-SAST-2-f sarif для bandit, --sarif для semgrep
Global skips без комментарияR-SEC-SAST-4комментарий с причиной и сроком рядом

Куда дальше

  • Security → раздел 1. SAST по коду — нормативные формулировки.
  • CVE в зависимостях — следующий слой: pip-audit, pip-audit ignore-list со сроком.
  • Секреты в коде и истории — Gitleaks: pre-commit hook, .gitleaks.toml.
  • Container/image-уязвимости — Trivy: digest-pinned base image, non-root.
  • Реакция на findings — Severity → SLA: CRITICAL ≤ 24ч, HIGH ≤ 2 нед.