SAST Testing for Python: Bandit, Semgrep, and CodeQL in CI/CD

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.call with shell=True
  • eval() / exec() on user input
  • MD5/SHA1 usage for passwords
  • assert statements 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 -i

Configure via .bandit:

[bandit]
skips = B101,B601
exclude_dirs = tests,migrations

B101 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.json

The || 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 patterns
  • p/django — Django-specific: XSS in templates, CSRF bypass, raw SQL
  • p/flask — Flask: debug mode in production, session misconfiguration
  • p/secrets — hardcoded API keys, tokens, connection strings
  • p/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: security

Run 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/secrets

CodeQL 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:python

CodeQL 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@v3

Triage Strategy

SAST tools produce false positives. A triage workflow keeps them from becoming noise:

  1. Severity threshold: Block on HIGH only, warn on MEDIUM, ignore LOW until sprint end
  2. Baseline file: Commit a bandit-baseline.json so only new issues fail the build
  3. Suppression comments: Use # nosec B602 for confirmed false positives — requires a comment explaining why
  4. 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.json

SAST + 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.

Read more

Start now free