Skip to main content
DevSecOps (AppSec in the pipeline) · lesson 2 of 7

Static Analysis (SAST) with Bandit and a Custom Semgrep Rule

Plant shell=True and yaml.load in a Python file, read Bandit's findings, write a Semgrep rule for a bug only your team knows about, and fix both to green.

By SkillByExample · Published

devsecops sast bandit semgrep python static-analysis

Static analysis reads your code without running it and flags patterns that have caused vulnerabilities before. This lesson plants two classic Python bugs and one bug specific to a made-up team’s codebase, catches each with a scanner, fixes them, and shows the exit codes a CI job would see. Tested with Bandit 1.9.4 and Semgrep 1.180.0 on Python 3.12.15 (python:3.12-slim) and Docker Engine 29.8.2. Every scan runs offline: Semgrep is given a local rule file and --metrics=off.

The lab

# Dockerfile
FROM python:3.12-slim
RUN pip install -q bandit==1.9.4 semgrep==1.180.0
WORKDIR /w
$ docker build -q -t sast-lab .
sha256:98ba9a7a35508253bc9efa127bf0b2e93462d4b304413964f0e07818c49fdadf

The code under test lives in an app/ folder. It’s never executed, only read, so it doesn’t need its imports installed.

Two bugs Bandit already knows

A deploy helper that reads a YAML config and restarts a service. Both functions work, and both are dangerous.

# app/deploy.py
import subprocess
import yaml


def load_config(path):
    with open(path) as f:
        return yaml.load(f, Loader=yaml.Loader)


def restart(service):
    subprocess.run(f"systemctl restart {service}", shell=True, check=True)

Bandit’s default report is long, so a one-line message template keeps each finding on one line.

$ docker run --rm -v "$PWD":/w sast-lab bandit -q -r app -f custom --msg-template "{relpath}:{line} {test_id} {severity} {msg}"
app/deploy.py:2 B404 LOW Consider possible security implications associated with the subprocess module.
app/deploy.py:8 B506 MEDIUM Use of unsafe yaml load. Allows instantiation of arbitrary objects. Consider yaml.safe_load().
app/deploy.py:12 B602 HIGH subprocess call with shell=True identified, security issue.
$ echo $?
1

The two real bugs are B602 and B506. With shell=True, a service name like web; rm -rf ~ runs a second command, and yaml.Loader can build arbitrary Python objects from tags in the file. B404 is different: it fires on the bare import subprocess, as a reminder to look at how the module is used. Bandit exits 1 whenever it reports anything, which is what makes it usable as a CI gate.

Fix them, and read what’s left

Pass the command as a list so no shell is involved, and use safe_load, which only builds plain dicts, lists and scalars.

# app/deploy.py
import subprocess
import yaml


def load_config(path):
    with open(path) as f:
        return yaml.safe_load(f)


def restart(service):
    subprocess.run(["systemctl", "restart", service], check=True)
$ docker run --rm -v "$PWD":/w sast-lab bandit -q -r app -f custom --msg-template "{relpath}:{line} {test_id} {severity} {msg}"
app/deploy.py:2 B404 LOW Consider possible security implications associated with the subprocess module.
app/deploy.py:12 B607 LOW Starting a process with a partial executable path
app/deploy.py:12 B603 LOW subprocess call - check for execution of untrusted input.
$ echo $?
1

The Medium and High findings are gone, but fixing the shell call produced two new Low ones. B603 fires on every subprocess call without a shell, asking you to confirm the input is trusted. B607 notes that systemctl is found through PATH rather than an absolute path. These are prompts for a reviewer, not bugs, and they’d fail a build that gates on every finding.

So gate on severity instead. -ll reports Medium and above.

$ docker run --rm -v "$PWD":/w sast-lab bandit -q -ll -r app
$ echo $?
0

Silent and exit 0: this is the build passing. The Low findings still show up in the full report, where a reviewer can read them; they just don’t block the merge.

A bug no generic scanner knows

This team’s rule is that every Flask route needs @login_required, except the health check. One route forgot.

# app/views.py
from flask import Flask
from app.auth import login_required

app = Flask(__name__)


@app.route("/health")
def health():
    return "ok"


@app.route("/invoices")
@login_required
def invoices():
    return "all invoices"


@app.route("/invoices/export")
def export_invoices():
    return "all invoices as CSV"

Run Bandit over app/ again and it still reports only the deploy.py lines, because a missing decorator isn’t a generic Python bug. A Semgrep rule is written in the shape of the code it matches: find every route, drop those that have the decorator, and skip health.

rules:
  - id: route-without-login
    languages: [python]
    severity: ERROR
    message: Route $F has no @login_required; add it or list it as public
    patterns:
      - pattern: |
          @app.route(...)
          def $F(...): ...
      - pattern-not: |
          @app.route(...)
          @login_required
          def $F(...): ...
      - metavariable-regex:
          metavariable: $F
          regex: ^(?!health$)

Save it as rules.yml next to app/, then run it.

$ docker run --rm -v "$PWD":/w sast-lab semgrep scan --config rules.yml --metrics=off --quiet --emacs app
app/views.py:19:1:error(route-without-login):@app.route("/invoices/export"):Route export_invoices has no @login_required; add it or list it as public
$ echo $?
0

The rule found the unprotected export route, and $F in the message was filled in with the function’s name. But look at the exit code: 0. By default semgrep scan reports findings and still succeeds, so a CI step using this exact command would pass with an open finding. You need --error.

$ docker run --rm -v "$PWD":/w sast-lab semgrep scan --config rules.yml --metrics=off --quiet --emacs --error app
app/views.py:19:1:error(route-without-login):@app.route("/invoices/export"):Route export_invoices has no @login_required; add it or list it as public
$ echo $?
1

Same finding, exit 1. Bandit and Semgrep have opposite defaults, and getting that wrong is a common reason a security step turns out to have never failed a build.

Fix the route

Add the decorator to export_invoices, between @app.route(...) and def, as on invoices.

$ docker run --rm -v "$PWD":/w sast-lab semgrep scan --config rules.yml --metrics=off --quiet --emacs --error app
$ echo $?
0

No output and exit 0. The rule now lives in the repository: the next route someone adds without the decorator fails the build the same way, so the review comment never has to be written twice.

Practice

  1. Put shell=True back in restart, but with a constant string: subprocess.run("systemctl restart web", shell=True, check=True). Does Bandit still report B602, and at which severity?

    Solution

    Yes, but at LOW instead of HIGH, with a different message: B602 LOW subprocess call with shell=True seems safe, but may be changed in the future, consider rewriting without shell. Bandit rates a string literal lower because nothing outside the code can change it. It still nudges you toward the list form, because the next edit may add a variable.

  2. Instead of lowering the threshold, suppress just the B603 finding on the subprocess.run line.

    Solution

    End the line with # nosec B603 and put the reason in a comment on the line above. Re-run the template command: B603 disappears, while B404 and B607 remain. Don’t write the reason after the ID on the same line: Bandit 1.9.4 parses every word there as a test name and prints a Test in comment: ... is not a test name or id warning for each.

  3. Add a public route /robots.txt without @login_required. Extend the rule so it is allowed.

    Solution

    Name the function robots, then change the regex to ^(?!(health|robots)$). The negative lookahead now skips both names. A team usually keeps that allow-list in the rule file, so adding a public route is a reviewed change to rules.yml.

  4. Run Bandit with -lll on the original vulnerable deploy.py. Which finding is left?

    Solution

    -lll reports High severity only, so only B602 (shell=True) remains and B506 is hidden. That shows why -lll is usually too loose for a gate: the unsafe YAML load is a real bug at Medium.

Where this fits

SAST reads the code you wrote. The next lesson looks at something it can’t see: a credential that was committed once and then deleted, still sitting in git history.

Frequently Asked Questions

What is SAST?
Static application security testing reads source code without running it and flags patterns known to cause vulnerabilities, such as shell command strings built from input or unsafe deserialisation. It runs in seconds, so it fits in a pre-commit hook or a pull-request check. It can't see runtime configuration, which is why it is paired with dynamic testing.
What is the difference between Bandit and Semgrep?
Bandit is a Python-only scanner with a fixed set of built-in checks, each with an ID such as B602 for subprocess with shell=True. Semgrep is a multi-language pattern engine: it ships rule packs, but its main strength is that you can write a rule in a few lines of YAML that looks like the code it matches. Teams commonly run Bandit for the generic Python checks and Semgrep for their own rules.
Why does Semgrep exit 0 when it finds issues?
By default semgrep scan reports findings but exits 0, so a CI step using it will pass. Add the --error flag to make it exit 1 when there are findings. Bandit is the opposite: it exits 1 whenever it reports anything, so you filter by severity with -l, -ll or -lll instead.
Is yaml.load with yaml.Loader dangerous?
Yes, when the input isn't fully trusted. The full Loader can construct arbitrary Python objects from YAML tags, which lets a crafted file run code during parsing. yaml.safe_load builds only plain types such as dicts, lists, strings and numbers, which is all a configuration file needs.
How do I suppress a false positive in Bandit?
Add a # nosec comment with the specific test ID, for example # nosec B603, at the end of the flagged line, and explain why in a comment on the line above. Bandit then counts it under 'skipped' instead of reporting it. Prefer suppressing one ID on one line over lowering the severity threshold for the whole project.