Most "how to add SAST to your pipeline" articles gravitate toward the same four names: SonarQube, Snyk, Semgrep, Veracode. They're solid tools, but they're not the only options, and sometimes you can't use them — budget constraints, air-gapped environments, licensing restrictions, or simply wanting something lightweight that lives entirely in your repo.
The OWASP Source Code Analysis Tools page lists dozens of alternatives across every language. In this article I'll walk through applying Bandit, a free, open-source SAST tool for Python, to a real sample application — from finding vulnerabilities locally to wiring it into a CI/CD pipeline with GitHub Actions.
The same workflow (install → configure → scan → fail the build on high-severity findings → track results over time) applies almost identically if you swap Bandit for other OWASP-listed tools like Brakeman (Ruby), FindSecBugs (Java), Gosec (Go), or Horusec (multi-language).
Why Bandit?
- 100% open source (Apache 2.0), maintained under the PyCQA org.
- No account, no server, no license key — it runs as a CLI or a library.
- Understands Python's AST, so it catches real logic patterns, not just regex matches.
- Easy to tune with a config file and inline
# nosecsuppressions. ## 1. The sample application
Let's use a small Flask app with a few intentionally introduced vulnerabilities — the kind of thing that slips into real codebases under deadline pressure.
# app.py
import subprocess
import sqlite3
import pickle
import yaml
from flask import Flask, request
app = Flask(__name__)
DB_PATH = "users.db"
@app.route("/ping")
def ping():
host = request.args.get("host")
# Vulnerable: command injection via shell=True
result = subprocess.run(f"ping -c 1 {host}", shell=True, capture_output=True)
return result.stdout
@app.route("/user")
def get_user():
user_id = request.args.get("id")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Vulnerable: SQL injection via string formatting
query = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(query)
return str(cursor.fetchall())
@app.route("/load", methods=["POST"])
def load_data():
payload = request.data
# Vulnerable: insecure deserialization
obj = pickle.loads(payload)
return str(obj)
@app.route("/config", methods=["POST"])
def load_config():
raw = request.data.decode()
# Vulnerable: unsafe YAML load
config = yaml.load(raw, Loader=yaml.Loader)
return str(config)
if __name__ == "__main__":
# Vulnerable: hardcoded secret + debug mode in "production"
app.secret_key = "super-secret-key-123"
app.run(debug=True, host="0.0.0.0")
This single file contains five classic weaknesses: OS command injection, SQL injection, insecure deserialization (pickle), unsafe YAML loading, and a hardcoded secret combined with debug mode exposed on all interfaces.
2. Installing and running Bandit locally
pip install bandit
# Scan a single file
bandit app.py
# Scan an entire project, recursively
bandit -r . -x ./venv,./tests
Sample output:
>> Issue: [B602:subprocess_popen_with_shell_equals_true] subprocess call with shell=True identified
Severity: High Confidence: High
Location: app.py:15
>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction
Severity: Medium Confidence: Low
Location: app.py:24
>> Issue: [B301:blacklist] Pickle library appears to be in use, possible security issue
Severity: Medium Confidence: High
Location: app.py:33
>> Issue: [B506:yaml_load] Use of unsafe yaml load
Severity: Medium Confidence: High
Location: app.py:41
>> Issue: [B105:hardcoded_password_string] Possible hardcoded password
Severity: Low Confidence: Medium
Location: app.py:47
>> Issue: [B201:flask_debug_true] A Flask app appears to be run with debug=True
Severity: High Confidence: Medium
Location: app.py:48
Every one of the five bugs we planted got caught, plus the debug-mode issue we didn't even think about.
3. Tuning it: a config file instead of noisy defaults
Create a .bandit (or bandit.yaml) file so scans are reproducible across machines and CI:
# bandit.yaml
exclude_dirs:
- tests
- venv
- .venv
skips:
- B101 # skip assert_used checks in test files
# Only fail the build on medium severity and above
assert_used:
skips: ['*_test.py']
Run it with:
bandit -r . -c bandit.yaml -f json -o bandit-report.json
The -f json flag is what makes this pipeline-friendly — you get a machine-readable report you can parse, upload as an artifact, or feed into a dashboard.
4. Failing the build only when it matters
A common mistake is treating every finding as a blocker, which trains developers to ignore the tool. Instead, gate the pipeline on severity:
bandit -r . -c bandit.yaml -lll -iii
-
-lllreports only HIGH severity issues. -
-iiireports only HIGH confidence issues. This gives you a strict "block on real problems" gate, while a separate informational report (-ll -ii, medium and up) can be published without failing the build — useful for tracking technical debt without blocking releases.
5. Automating it with GitHub Actions
Here's a full workflow that runs Bandit on every push and pull request, uploads the JSON report as a build artifact, and fails the job if high-severity issues are found:
# .github/workflows/sast-bandit.yml
name: SAST - Bandit
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
bandit-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Bandit
run: pip install bandit
- name: Run Bandit (full report, non-blocking)
run: bandit -r . -c bandit.yaml -f json -o bandit-report.json || true
- name: Upload Bandit report
uses: actions/upload-artifact@v4
with:
name: bandit-report
path: bandit-report.json
- name: Enforce high-severity gate
run: bandit -r . -c bandit.yaml -lll -iii
The last step is the actual gate: it re-runs Bandit filtered to high severity/high confidence, and its non-zero exit code fails the job, blocking the PR from merging while a vulnerable subprocess.run(..., shell=True) or debug=True sits in the diff.
6. Fixing the findings
For completeness, here's the same file after remediation, so you can see what Bandit considers "clean":
import subprocess
import sqlite3
import json
from flask import Flask, request
app = Flask(__name__)
DB_PATH = "users.db"
@app.route("/ping")
def ping():
host = request.args.get("host", "")
if not host.replace(".", "").isalnum():
return "invalid host", 400
result = subprocess.run(["ping", "-c", "1", host], capture_output=True)
return result.stdout
@app.route("/user")
def get_user():
user_id = request.args.get("id")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
return str(cursor.fetchall())
@app.route("/load", methods=["POST"])
def load_data():
obj = json.loads(request.data)
return str(obj)
if __name__ == "__main__":
import os
app.secret_key = os.environ["APP_SECRET_KEY"]
app.run(debug=False, host="127.0.0.1")
Running bandit -r . again against this version returns zero findings.
Takeaways
- SAST doesn't require a commercial platform or a specific well-known brand. The OWASP list has a free, actively maintained tool for nearly every language.
- The value isn't the tool itself — it's wiring it into CI so vulnerable patterns get caught before merge, with a severity-based gate so you don't drown developers in noise.
- Start with a permissive, informational scan; tighten the gate over time as the codebase gets cleaner. ## Demo repository
The vulnerable app, the fixed version, the bandit.yaml config, and the GitHub Actions workflow shown above are all in this repo, ready to fork and run:
👉 https://github.com/<your-username>/sast-bandit-demo
Fork it, push a change, and watch the Actions tab catch (or clear) the findings automatically.
If this was useful, drop a comment with which SAST tool from the OWASP list you'd like to see covered next — Brakeman, Gosec, and FindSecBugs are on my list.
Top comments (0)