Опирается на правила:
R-SEC-SAST-1…R-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-набор ловит (без запуска кода):
S101—assertв production-коде (убирается оптимизатором).S105/S106— hardcoded password в строке или функции.S108— небезопасный/tmpбезtempfile.S301—pickle.loadsбез проверки источника.S324—hashlib.md5/sha1безusedforsecurity=False.S501—requestsбез верификации 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:
B601—paramiko.exec_command(userInput)— command injection через SSH.B608—f"SELECT * FROM orders WHERE id = {order_id}"— SQL injection.B102/B603—os.system(cmd)/subprocess.call(cmd, shell=True)— shell injection.B301—pickle.loads(data)— небезопасная десериализация.B105—password = "sber_secret_2024"— hardcoded credential.B411—xmlrpc— устаревший и небезопасный протокол.B324—hashlib.sha1(data)— weak hash для security (cross-refR-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.
| Severity | bandit/semgrep | ruff/mypy | Действие |
|---|---|---|---|
| CRITICAL | HIGH-confidence security | error | Сборка падает |
| HIGH | HIGH severity | error | Сборка падает |
| MEDIUM | MEDIUM severity | warn | Обязательный комментарий ревьюера |
| LOW | LOW 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 в CI | R-SEC-SAST-3 | убрать --exit-zero, сборка падает на HIGH+ |
semgrep без --error | R-SEC-SAST-3 | флаг --error обязателен |
| MEDIUM findings без комментария ревьюера | R-SEC-SAST-3 | обязательный комментарий в PR |
ruff без S-набора | R-SEC-SAST-1 | select = ["S", ...] в pyproject.toml |
| Без SARIF output | R-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 нед.