DevSecOps (AppSec in the pipeline) · lesson 2 of 7
Beginner
3Intermediate
3Also part of DevSecOps (AppSec in the pipeline)
10- + Bash Security Best Practices from Bash
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + Docker Security from Docker
- + Multi-Stage Builds from Docker
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
- + Scanning Terraform for Misconfiguration with Checkov from Cloud Security
- + Policy as Code: Testing Terraform Plans with OPA and Conftest from Cloud Security
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
- + Running MCP in Production: Scaling, Caching and Operations from MCP (Model Context Protocol)
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
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
-
Put
shell=Trueback inrestart, 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. -
Instead of lowering the threshold, suppress just the B603 finding on the
subprocess.runline.Solution
End the line with
# nosec B603and 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 aTest in comment: ... is not a test name or idwarning for each. -
Add a public route
/robots.txtwithout@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 torules.yml. -
Run Bandit with
-lllon the original vulnerabledeploy.py. Which finding is left?Solution
-lllreports High severity only, so onlyB602(shell=True) remains and B506 is hidden. That shows why-lllis 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.