SAST Testing for Python: Bandit, Semgrep, and CodeQL in CI/CD
Static Application Security Testing (SAST) for Python means scanning your source code for vulnerabilities without running it. Bandit is the go-to Python-specific tool, Semgrep gives you customizable rules, and CodeQL provides deep dataflow analysis. This guide shows you how to configure all three in CI/CD and which vulnerability classes each one catches.
Why Python SAST is Non-Negotiable
Python's dynamic nature and permissive syntax make it easy to introduce security bugs: SQL built with string concatenation, subprocess calls with user input, pickle.loads on untrusted data, hardcoded API keys in source files.
SAST catches these at commit time — before code review, before QA, before production. The cost of fixing a vulnerability in code review is roughly 6× cheaper than fixing it post-deployment.
The Python SAST Toolchain
Three tools cover the Python security scanning landscape:
| Tool | Type | Best For |
|---|---|---|
| Bandit | Python-specific | Quick wins, Django/Flask patterns |
| Semgrep | Rule-based, multi-language | Custom rules, team-specific patterns |
| CodeQL | Dataflow analysis | Complex injection chains, GitHub integration |
Bandit
Bandit is a pure-Python SAST tool built specifically for Python codebases. It maps directly to CWE categories and flags common patterns like:
subprocess.callwithshell=Trueeval()/exec()on user input- MD5/SHA1 usage for passwords
assertstatements for auth logic- Hardcoded passwords and tokens
- SQL string concatenation
Install and run:
pip install bandit
bandit -r src/ -f json -o bandit-report.json
# Show high-severity issues only
bandit -r src/ -ll -iConfigure via .bandit:
[bandit]
skips = B101,B601
exclude_dirs = tests,migrationsB101 skips assert warnings (common in tests), B601 skips paramiko warnings you've already reviewed.
GitHub Actions:
- name: Run Bandit
run: |
pip install bandit
bandit -r src/ -f json -o bandit-report.json || true
- name: Upload Bandit Report
uses: actions/upload-artifact@v4
with:
name: bandit-report
path: bandit-report.jsonThe || true prevents the step from failing the build — review findings as warnings first, then graduate to hard failures once you've cleaned up existing issues.
Semgrep for Python
Semgrep's Python rules cover everything Bandit does plus custom patterns your team defines. The p/python and p/django rulesets are maintained by Semgrep Inc. and updated when new CVEs drop.
Install and run:
pip install semgrep
semgrep --config p/python --config p/django src/Key rulesets:
p/python— general Python security patternsp/django— Django-specific: XSS in templates, CSRF bypass, raw SQLp/flask— Flask: debug mode in production, session misconfigurationp/secrets— hardcoded API keys, tokens, connection stringsp/owasp-top-ten— OWASP mapped rules
Write a custom rule (.semgrep/rules/no-pickle-untrusted.yaml):
rules:
- id: no-pickle-untrusted-input
patterns:
- pattern: pickle.loads($INPUT)
- pattern-not: pickle.loads(open(...).read())
message: "Deserializing untrusted data with pickle is dangerous (arbitrary code execution)"
languages: [python]
severity: ERROR
metadata:
cwe: "CWE-502"
category: securityRun your custom rules alongside community rules:
semgrep --config p/python --config .semgrep/rules/ src/CI integration (GitHub Actions):
- uses: returntocorp/semgrep-action@v1
with:
config: >-
p/python
p/django
p/secretsCodeQL for Python
CodeQL performs dataflow analysis — it tracks how user-controlled input flows through your application to dangerous sinks. This catches multi-step injection chains that Bandit and Semgrep miss.
Example: user input from request.GET['q'] → string formatting → os.system(). CodeQL traces that entire path.
Setup in GitHub Actions:
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: python
queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: /language:pythonCodeQL results appear in GitHub's Security tab under Code Scanning Alerts. No separate report file needed.
What CodeQL catches that Bandit misses:
- SQL injection across function boundaries (query built in helper, executed in view)
- Command injection through template rendering
- Path traversal through multiple string operations
- Taint flows through class methods and callbacks
Common Python Vulnerabilities SAST Catches
SQL Injection
# VULNERABLE — Bandit B608, Semgrep django.sql-injection
def get_user(username):
query = "SELECT * FROM users WHERE name = '" + username + "'"
cursor.execute(query)
# SAFE
def get_user(username):
cursor.execute("SELECT * FROM users WHERE name = %s", [username])Command Injection
# VULNERABLE — Bandit B602
import subprocess
def ping(host):
subprocess.call("ping -c 1 " + host, shell=True)
# SAFE
def ping(host):
subprocess.call(["ping", "-c", "1", host])Insecure Deserialization
# VULNERABLE — Bandit B301
import pickle
def load_session(data):
return pickle.loads(data) # Arbitrary code execution
# SAFE — use json for untrusted data
import json
def load_session(data):
return json.loads(data)Hardcoded Secrets
# VULNERABLE — Semgrep p/secrets
AWS_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
# SAFE
import os
AWS_SECRET_KEY = os.environ["AWS_SECRET_KEY"]Running All Three Together
A layered approach catches the most issues:
# .github/workflows/sast.yml
name: Python SAST
on: [push, pull_request]
jobs:
bandit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install bandit
- run: bandit -r src/ -f json -o bandit.json -ll
- uses: actions/upload-artifact@v4
with:
name: bandit
path: bandit.json
semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: returntocorp/semgrep-action@v1
with:
config: "p/python p/django p/secrets"
codeql:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: python
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3Triage Strategy
SAST tools produce false positives. A triage workflow keeps them from becoming noise:
- Severity threshold: Block on HIGH only, warn on MEDIUM, ignore LOW until sprint end
- Baseline file: Commit a
bandit-baseline.jsonso only new issues fail the build - Suppression comments: Use
# nosec B602for confirmed false positives — requires a comment explaining why - Weekly triage: Dedicate 30 minutes to reviewing MEDIUM findings
Create a Bandit baseline:
bandit -r src/ -f json -o .bandit-baseline.json
# Commit .bandit-baseline.json
# Future runs:
bandit -r src/ --baseline .bandit-baseline.jsonSAST + Runtime Monitoring
SAST finds vulnerabilities before runtime. For Python apps in production, pair it with runtime monitoring:
- HelpMeTest — run automated tests against your app after every deploy, including tests that verify security controls work (auth gates, input validation, rate limiting)
- OWASP ZAP in CI — dynamic scanning after deployment to staging
SAST is shift-left; runtime testing is the safety net. Both are required.
Summary
| Tool | Catches | Speed | Setup Effort |
|---|---|---|---|
| Bandit | Python-specific patterns, CWE-mapped | Seconds | 5 minutes |
| Semgrep | Custom + community rules, multi-framework | Seconds-minutes | 30 minutes |
| CodeQL | Dataflow, taint analysis, cross-function | Minutes | 1 hour (GitHub Actions) |
Start with Bandit for immediate wins. Add Semgrep rulesets for your specific frameworks. Add CodeQL when your codebase is large enough that multi-step injection chains become a real risk.
The goal isn't zero findings — it's knowing about vulnerabilities before attackers do.