DEV Community

Cover image for Applying SAST to Any Application (Without Sonar, Snyk, Semgrep, or Veracode)
IKER ALBERTO SIERRA RUIZ
IKER ALBERTO SIERRA RUIZ

Posted on

Applying SAST to Any Application (Without Sonar, Snyk, Semgrep, or Veracode)

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 # nosec suppressions. ## 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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

Run it with:

bandit -r . -c bandit.yaml -f json -o bandit-report.json
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • -lll reports only HIGH severity issues.
  • -iii reports 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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)