Security Scanning and Code Quality¶
Security is not optional. Modern Python projects use automated tools to detect vulnerabilities, dead code, and dependency issues before they reach production.
Security Mindset¶
Security First
- Shift left: Catch issues in development, not production
- Automate everything: Manual review misses issues
- Multiple layers: Use multiple tools for comprehensive coverage
- Regular updates: Security tools and dependencies must stay current
- Zero tolerance: Fix critical issues immediately
The Security Stack¶
graph TD
A[Code Written] --> B[Bandit: Security Scan]
B --> C[Vulture: Dead Code]
C --> D[pip-audit: Dependencies]
D --> E{All Pass?}
E -->|No| F[Fix Issues]
E -->|Yes| G[Pre-commit]
F --> A
G --> H[CI/CD]
H --> I[Production]
style B fill:#ff6b6b
style C fill:#ffd93d
style D fill:#6bcf7f
Bandit: Security Scanning¶
Bandit finds common security issues in Python code. It scans for SQL injection, command injection, hardcoded passwords, insecure functions, and more.
Why Bandit?¶
Key Benefits
- Automated security review - Finds issues humans miss
- Common vulnerabilities - Detects OWASP Top 10 issues
- Low false positives - Well-tuned default rules
- Pre-commit integration - Catch issues before commit
- Configurable - Exclude tests, adjust severity
Installation¶
Basic Usage¶
# Scan current directory recursively
bandit -r .
# Scan specific file
bandit myapp/views.py
# Scan with higher severity only
bandit -r . -ll # Low severity and above
bandit -r . -l # Medium severity and above
# Output to file
bandit -r . -f json -o bandit-report.json
Configuration (.bandit)¶
Create .bandit file in project root:
[bandit]
# Exclude directories from scanning
exclude: /tests,/migrations,/venv,/node_modules
# Skip specific test IDs
skips: B101,B601
# Show only high severity issues
#severity_level: HIGH
# Show only high confidence issues
#confidence_level: HIGH
Common Security Issues¶
1. Hardcoded Passwords (B105, B106)¶
# ❌ Bad: Hardcoded password
PASSWORD = "super_secret_123"
def connect():
return connect_db(password="hardcoded") # Flagged by Bandit
# ✅ Good: Use environment variables
import os
PASSWORD = os.environ.get("DB_PASSWORD")
def connect():
return connect_db(password=PASSWORD)
2. SQL Injection (B608)¶
# ❌ Bad: SQL injection risk
def get_user(user_id):
query = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(query) # Flagged by Bandit
# ✅ Good: Parameterized queries
def get_user(user_id):
query = "SELECT * FROM users WHERE id = %s"
cursor.execute(query, (user_id,))
3. Command Injection (B602, B605)¶
# ❌ Bad: Shell injection risk
import os
def backup_file(filename):
os.system(f"cp {filename} backup/") # Flagged by Bandit
# ✅ Good: Use subprocess with list
import subprocess
def backup_file(filename):
subprocess.run(["cp", filename, "backup/"], check=True)
4. Insecure Random (B311)¶
# ❌ Bad: Weak random for security
import random
def generate_token():
return random.randint(1000, 9999) # Flagged by Bandit
# ✅ Good: Cryptographically secure random
import secrets
def generate_token():
return secrets.token_urlsafe(32)
5. Assert in Production (B101)¶
# ❌ Bad: Asserts disabled in production
def validate_user(user):
assert user.is_authenticated # Flagged by Bandit
return user.data
# ✅ Good: Raise exceptions
def validate_user(user):
if not user.is_authenticated:
raise PermissionError("User not authenticated")
return user.data
6. Insecure Deserialization (B301, B302)¶
# ❌ Bad: Arbitrary code execution risk
import pickle
def load_data(data):
return pickle.loads(data) # Flagged by Bandit
# ✅ Good: Use JSON or safe formats
import json
def load_data(data):
return json.loads(data)
7. Try-Except-Pass (B110)¶
# ❌ Bad: Silent failures hide issues
try:
risky_operation()
except Exception:
pass # Flagged by Bandit
# ✅ Good: Log errors
import logging
logger = logging.getLogger(__name__)
try:
risky_operation()
except Exception as e:
logger.error(f"Operation failed: {e}")
raise
Excluding False Positives¶
Line-Level Exclusions¶
# Exclude specific issue on line
password = "test_password_for_dev" # nosec B105
# Exclude with comment
import subprocess
subprocess.call(shell=True) # nosec B602 - safe in this context
File-Level Exclusions¶
Configuration Exclusions¶
# .bandit
[bandit]
# Exclude test files
exclude: /tests
# Skip specific tests globally
skips: B101,B601
Bandit Test IDs¶
Common Bandit test IDs to know:
| ID | Issue | Severity | Example |
|---|---|---|---|
| B101 | Assert used | Low | assert user.is_admin |
| B105 | Hardcoded password string | Medium | pwd = "secret" |
| B106 | Hardcoded password argument | Medium | connect(pwd="test") |
| B110 | Try-except-pass | Low | except: pass |
| B201 | Flask debug=True | High | app.run(debug=True) |
| B301 | Pickle usage | Medium | pickle.loads(data) |
| B303 | MD5/SHA1 usage | Medium | hashlib.md5() |
| B310 | URLopen without timeout | Medium | urllib.urlopen(url) |
| B311 | Random for crypto | Low | random.random() |
| B404 | Subprocess import | Low | import subprocess |
| B602 | Shell=True | High | subprocess.call(shell=True) |
| B608 | SQL injection | High | execute(f"SELECT {x}") |
Pre-commit Integration¶
# .pre-commit-config.yaml
repos:
- repo: https://github.com/PyCQA/bandit
rev: 1.8.0
hooks:
- id: bandit
language_version: python3
args:
- -l # Show medium+ severity
- --recursive # Scan recursively
- -x # Exclude paths
- tests,migrations # Directories to exclude
- -q # Quiet mode
- -s # Skip test IDs
- B310,B410,B608,B101,B314,B404 # Tests to skip
files: \.py$
Pre-commit Strategy
- Skip B101 (assert) in tests
- Skip B404 (subprocess import) - too many false positives
- Use
-lto show medium+ severity - Use
-qfor cleaner output
CI/CD Integration¶
GitHub Actions¶
name: Security
on: [push, pull_request]
jobs:
bandit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Bandit
run: pip install bandit
- name: Run Bandit
run: bandit -r . -f json -o bandit-report.json
- name: Upload report
uses: actions/upload-artifact@v3
with:
name: bandit-report
path: bandit-report.json
GitLab CI¶
bandit:
image: python:3.13-slim
script:
- pip install bandit
- bandit -r . -f json -o bandit-report.json
artifacts:
reports:
bandit: bandit-report.json
paths:
- bandit-report.json
Vulture: Dead Code Detection¶
Vulture finds unused code - functions, classes, variables, imports that are never used. Keeping code clean reduces maintenance burden and attack surface.
Why Vulture?¶
Key Benefits
- Find dead code - Unused functions, imports, variables
- Reduce bloat - Smaller, faster codebase
- Security benefit - Less code = less attack surface
- Improve clarity - Remove confusing unused code
- Confidence scoring - Adjustable sensitivity
Installation¶
Basic Usage¶
# Scan current directory
vulture .
# Scan with minimum confidence (0-100)
vulture . --min-confidence 80
# Scan and sort by confidence
vulture . --sort-by-size
# Make whitelist from current results
vulture . --make-whitelist > .vulture-whitelist.py
Configuration¶
Create .vulture.py whitelist file:
"""
Vulture whitelist file.
This lists variables/functions that appear unused but should not be removed.
"""
# Django model fields (accessed dynamically)
objects = "Model.objects"
DoesNotExist = "Model.DoesNotExist"
MultipleObjectsReturned = "Model.MultipleObjectsReturned"
# Pytest fixtures (appear unused but tests depend on them)
db = "db"
client = "client"
user = "user"
authenticated_client = "authenticated_client"
# Django settings
DATABASES = "DATABASES"
INSTALLED_APPS = "INSTALLED_APPS"
MIDDLEWARE = "MIDDLEWARE"
# Django admin
admin_site = "admin_site"
short_description = "short_description"
# Celery tasks (registered but not directly called)
send_email_task = "send_email_task"
process_payment_task = "process_payment_task"
Common False Positives¶
1. Pytest Fixtures¶
# Appears unused but tests depend on it
@pytest.fixture
def user(db): # Vulture flags this
return User.objects.create(username="test")
# In conftest.py or test files
# Add to .vulture.py whitelist
user = "user"
db = "db"
2. Django Model Methods¶
class User(models.Model):
username = models.CharField(max_length=100)
def __str__(self): # Appears unused
return self.username
# Add to whitelist
__str__ = "__str__"
__repr__ = "__repr__"
3. Django Signals¶
@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs): # Appears unused
if created:
Profile.objects.create(user=instance)
# Add to whitelist
create_profile = "create_profile"
4. Class Properties¶
class User(models.Model):
@property
def full_name(self): # May appear unused
return f"{self.first_name} {self.last_name}"
# Add to whitelist if false positive
full_name = "full_name"
Running Vulture¶
# Basic scan
vulture .
# With whitelist
vulture . .vulture.py
# High confidence only (less false positives)
vulture . --min-confidence 100
# Exclude directories
vulture . --exclude "migrations,node_modules,venv"
# Sort by confidence
vulture . --sort-by-size
Pre-commit Integration¶
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: vulture
name: vulture
entry: vulture
language: system
pass_filenames: false
args:
- .
- .vulture.py
- --min-confidence
- "100"
- --exclude
- "venv,node_modules,migrations"
files: ^((?!tests/).)*\.py$ # Exclude tests
From zenith production project:
- repo: local
hooks:
- id: vulture
name: vulture
entry: vulture .vulture.py --min-confidence 100 --exclude "venv,node_modules,migrations,management,fixtures/cassettes,.md,conftest_playwright.py"
language: system
pass_filenames: false
files: ^poseidon/tests/
exclude: ^poseidon/tests/(README|TEST_DATABASE_README)\.md$
Real-World Whitelist Example¶
From zenith production project:
"""
Vulture whitelist file.
This lists variables/functions that appear unused but should not be removed.
"""
# Pytest fixtures that appear unused, but tests depend on them
db = "db"
session = "session"
load_user_fixtures = "load_user_fixtures"
load_people_fixtures = "load_people_fixtures"
authenticated_it_client = "authenticated_it_client"
test_clickup_data = "test_clickup_data"
tenant_db = "tenant_db"
vcr = "vcr"
django_user_model = "django_user_model"
User = "User"
event = "event"
Best Practices¶
Vulture Configuration
- min-confidence 100: Only show definite dead code
- Maintain whitelist: Document why code appears unused
- Run regularly: Weekly or on demand
- Exclude tests: Often have false positives
- Review carefully: Don't blindly delete flagged code
pip-audit: Dependency Vulnerabilities¶
pip-audit scans Python dependencies for known security vulnerabilities using the PyPI advisory database.
Installation¶
Basic Usage¶
# Audit installed packages
pip-audit
# Audit requirements file
pip-audit -r requirements.txt
# Show only vulnerabilities
pip-audit --desc
# Output as JSON
pip-audit --format json
# Fix vulnerabilities automatically
pip-audit --fix
Common Vulnerabilities¶
1. Outdated Dependencies¶
# Example output
Found 2 known vulnerabilities in 2 packages
Name Version ID Fix Versions
django 3.2.0 PYSEC-2023-123 >=3.2.20
requests 2.25.0 PYSEC-2023-456 >=2.31.0
2. Transitive Dependencies¶
# Vulnerable dependency of a dependency
Found 1 known vulnerability in 1 package
Name Version ID Fix Versions
urllib3 1.26.0 PYSEC-2023-789 >=1.26.18
└── requests 2.31.0 (requires urllib3<3,>=1.21.1)
CI/CD Integration¶
GitHub Actions¶
name: Security Audit
on:
schedule:
- cron: '0 0 * * 1' # Weekly
push:
branches: [main]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: |
pip install pip-audit
pip install -r requirements.txt
- name: Run pip-audit
run: pip-audit --desc --format json -o audit.json
- name: Upload audit report
uses: actions/upload-artifact@v3
with:
name: security-audit
path: audit.json
GitLab CI¶
audit:
image: python:3.13-slim
script:
- pip install pip-audit
- pip install -r requirements.txt
- pip-audit --desc
only:
- schedules
- main
Dependency Management Best Practices¶
Keep Dependencies Updated
- Pin versions: Use
==in requirements.txt - Range in source: Use
>=in requirements.in - Update regularly: Monthly or quarterly
- Test updates: Run full test suite after updates
- Monitor advisories: Subscribe to security mailing lists
# Pin exact versions in requirements.txt
Django==4.2.8
requests==2.31.0
celery==5.3.4
# Use ranges in requirements.in
Django>=4.2,<5.0
requests>=2.31.0
celery>=5.3.0
Additional Security Tools¶
1. Safety (Alternative to pip-audit)¶
# Install
uv pip install safety
# Check installed packages
safety check
# Check requirements file
safety check -r requirements.txt
# JSON output
safety check --json
2. Semgrep (Advanced Security)¶
# Install
pip install semgrep
# Run with default rules
semgrep --config auto
# Python-specific rules
semgrep --config p/python
# OWASP Top 10 rules
semgrep --config p/owasp-top-ten
# Custom rules
semgrep --config custom-rules.yml
3. Trivy (Container Scanning)¶
# Install Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Scan filesystem
trivy fs .
# Scan Docker image
trivy image myapp:latest
# Scan for secrets
trivy fs --scanners secret .
Security Checklist¶
Development¶
- Bandit configured in pre-commit
- Vulture whitelist maintained
- pip-audit runs weekly
- No hardcoded secrets in code
- Environment variables for sensitive data
- Parameterized SQL queries
- Secure random for tokens
- Input validation on all user data
CI/CD¶
- Bandit fails build on high severity
- pip-audit runs on every deploy
- Container scanning enabled
- Dependency updates automated
- Security advisories monitored
- SAST tools integrated
- Secret scanning enabled
Production¶
- Debug mode disabled
- HTTPS enforced
- Security headers configured
- Rate limiting enabled
- Logging sensitive operations
- Regular security audits
- Incident response plan
justfile Integration¶
# Run all security checks
security:
bandit -r . -ll
vulture . .vulture.py --min-confidence 100
pip-audit
# Run Bandit only
bandit:
bandit -r . -ll -x tests
# Run Vulture with whitelist
vulture:
vulture . .vulture.py --min-confidence 100
# Audit dependencies
audit:
pip-audit -r requirements.txt
# Fix vulnerable dependencies
audit-fix:
pip-audit --fix
# Generate Vulture whitelist
vulture-whitelist:
vulture . --make-whitelist > .vulture-whitelist.py
# Full pre-commit check
check: security
ruff format --check .
ruff check .
pytest
Real-World Configuration Examples¶
From zenith Production Project¶
.bandit¶
.pre-commit-config.yaml (Bandit)¶
- repo: https://github.com/PyCQA/bandit
rev: 1.8.0
hooks:
- id: bandit
language_version: python3
args:
- -l
- --recursive
- -x
- tests
- -q
- -s
- B310,B410,B608,B101,B314,B404
files: \.py$
.pre-commit-config.yaml (Vulture)¶
- repo: local
hooks:
- id: vulture
name: vulture
entry: vulture .vulture.py --min-confidence 100 --exclude "venv,node_modules,migrations,management,fixtures/cassettes,.md,conftest_playwright.py"
language: system
pass_filenames: false
files: ^poseidon/tests/
exclude: ^poseidon/tests/(README|TEST_DATABASE_README)\.md$
Common Security Patterns¶
Environment Variables¶
# ❌ Bad
SECRET_KEY = "django-insecure-hardcoded-key"
DATABASE_URL = "postgresql://user:password@localhost/db"
# ✅ Good
import os
from django.core.exceptions import ImproperlyConfigured
def get_env_variable(var_name):
"""Get environment variable or raise exception."""
try:
return os.environ[var_name]
except KeyError:
raise ImproperlyConfigured(f"Set {var_name} environment variable")
SECRET_KEY = get_env_variable("SECRET_KEY")
DATABASE_URL = get_env_variable("DATABASE_URL")
Secure Password Handling¶
# ❌ Bad
from hashlib import md5
def hash_password(password):
return md5(password.encode()).hexdigest() # Weak!
# ✅ Good
from django.contrib.auth.hashers import make_password, check_password
def hash_password(password):
return make_password(password) # Uses PBKDF2 by default
def verify_password(password, hashed):
return check_password(password, hashed)
Safe Command Execution¶
# ❌ Bad
import os
def run_command(user_input):
os.system(f"ls {user_input}") # Command injection!
# ✅ Good
import subprocess
def run_command(user_input):
result = subprocess.run(
["ls", user_input],
capture_output=True,
text=True,
check=True,
timeout=30
)
return result.stdout
Input Validation¶
# ❌ Bad
def get_user(user_id):
query = f"SELECT * FROM users WHERE id = {user_id}"
return db.execute(query)
# ✅ Good
from django.core.validators import validate_integer
def get_user(user_id):
try:
user_id = int(user_id) # Type validation
validate_integer(user_id) # Django validator
except (ValueError, ValidationError):
raise ValueError("Invalid user ID")
return User.objects.get(id=user_id) # ORM prevents injection
Secure File Uploads¶
# ❌ Bad
def upload_file(request):
file = request.FILES['file']
with open(f"/uploads/{file.name}", 'wb') as f:
f.write(file.read())
# ✅ Good
import os
from django.core.files.storage import default_storage
from django.core.exceptions import ValidationError
ALLOWED_EXTENSIONS = {'.jpg', '.png', '.pdf'}
def upload_file(request):
file = request.FILES['file']
# Validate extension
ext = os.path.splitext(file.name)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise ValidationError("File type not allowed")
# Validate size (10MB max)
if file.size > 10 * 1024 * 1024:
raise ValidationError("File too large")
# Save with secure filename
filename = default_storage.save(file.name, file)
return filename
Troubleshooting¶
Bandit Issues¶
# Too many false positives
bandit -r . -ll -s B101,B404 # Skip common false positives
# Exclude test files
bandit -r . -x tests,migrations
# Review specific test
bandit --help | grep B608
Vulture Issues¶
# Too many false positives
vulture . --min-confidence 100 # Only show definite dead code
# Create whitelist
vulture . --make-whitelist > .vulture.py
# Exclude specific directories
vulture . --exclude "tests,migrations"
pip-audit Issues¶
# Transitive dependency issues
pip-audit --desc # Show full dependency tree
# Fix specific package
pip install package_name==fixed_version
# Ignore specific vulnerabilities (with caution)
pip-audit --ignore-vuln PYSEC-2023-123
Best Practices Summary¶
✅ Do¶
- Run security tools in pre-commit - Catch issues before commit
- Automate dependency audits - Weekly or on every deploy
- Maintain Vulture whitelist - Document false positives
- Use environment variables - Never hardcode secrets
- Parameterize SQL - Prevent injection attacks
- Validate all input - Trust nothing from users
- Keep dependencies updated - Security patches are critical
- Use secure defaults - Django, Flask have good defaults
- Log security events - Failed logins, permission errors
- Review security alerts - GitHub, GitLab, email advisories
❌ Don't¶
- Don't commit secrets or API keys
- Don't use weak cryptography (MD5, SHA1)
- Don't trust user input
- Don't use
shell=Truein subprocess - Don't ignore security warnings
- Don't use assert for validation
- Don't pickle untrusted data
- Don't disable security tools to pass CI
Further Reading¶
- Bandit Documentation
- Vulture Documentation
- pip-audit Documentation
- OWASP Python Security
- Python Security Best Practices
- Django Security
Next Steps¶
- Testing with pytest - Comprehensive testing strategies
- Pre-commit Hooks - Automate security checks
- CI/CD Integration - Security in deployment pipelines
- Secrets Management - Handling sensitive configuration
- Best Practices - Production security patterns