Skip to content

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

# Install Bandit
uv pip install bandit

# Or in requirements/dev.in
bandit>=1.8.0

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

# At top of file - exclude all issues
# flake8: noqa
# Or exclude specific Bandit tests
# nosec

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 -l to show medium+ severity
  • Use -q for 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

# Install Vulture
uv pip install vulture

# Or in requirements/dev.in
vulture>=2.10

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

# Install pip-audit
uv pip install pip-audit

# Or in requirements/dev.in
pip-audit>=2.7.0

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

[bandit]
exclude: poseidon/tests

.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=True in 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

Next Steps