Skip to content

Finding Security Vulnerabilities with Bandit

Bandit scans for patterns like:

  • use of eval
  • subprocess calls without safety
  • hardcoded passwords
  • insecure temporary files
bash
bandit -r your_package
danger_eval.py
# Bandit will warn about eval usage
user_input = "2 + 2"
result = eval(user_input)
  • Treat Bandit warnings as “review required”, not always “bug”.
  • Combine with dependency scanning in CI.

Every measurement on this page — and on the other five tools in this phase — comes from running the tool against this deliberately flawed file:

sample.py
import os
import subprocess
import hashlib
 
 
def process(items, user_input, flag = False):
    unused_var = 42
    result=[]
    for i in items:
        if i > 0:
            if flag:
                if i % 2 == 0:
                    result.append(i*2)
                else:
                    result.append(i)
            else:
                result.append(i)
    password = "hunter2"
    h = hashlib.md5(password.encode()).hexdigest()
    os.system("echo " + user_input)
    subprocess.call("ls " + user_input, shell=True)
    return result
 
 
def add(a: int, b: int) -> int:
    return a + b
 
 
x = add("1", 2)
toolwhat it reported on sample.py
flake85 style and dead-code issues. No security findings.
pylint4 issues, score 8.10/10
mypy1 type error, which neither linter saw
bandit5 security issues, 3 of them HIGH
radoncomplexity A (5), maintainability A (56.30)

The headline is that no tool subsumes another. flake8 read the whole file and reported nothing about the shell injection on line 20. bandit read the same file and said nothing about the type error on line 29. Running one and concluding the code is clean is the mistake this phase exists to prevent.

sketch Six tools, one file, almost no overlap p5.js
Each tool was run against the same 29-line file. Click a tool to see what it found and, more importantly, what it did not.
bandit sample.py
LOW      B404  line 2:  Consider possible security implications of the subprocess module
LOW      B105  line 18: Possible hardcoded password: 'hunter2'
HIGH     B324  line 19: Use of weak MD5 hash for security
HIGH     B605  line 20: Starting a process with a shell, possible injection detected
HIGH     B602  line 21: subprocess call with shell=True identified

Three HIGH severity findings. flake8 reported five style issues on the same file and none of these. pylint scored it 8.10/10. mypy found an unrelated type error. Only bandit was asking whether the code was dangerous.

diagram Diagram mermaid

The three HIGH findings, and why they are HIGH

Section titled “The three HIGH findings, and why they are HIGH”
injection.py
os.system("echo " + user_input)                        # B605
subprocess.call("ls " + user_input, shell=True)        # B602

A user sending ; rm -rf ~ runs their command, not yours. The fix removes the shell entirely — pass a list and no shell is involved, so there is nothing to inject into:

safe.py
subprocess.run(["ls", user_input], check=True)         # no shell, no injection
hashing.py
hashlib.md5(password.encode()).hexdigest()             # B324

MD5 is fast and broken. For passwords, use a deliberately slow function — scrypt via werkzeug.security.generate_password_hash, or argon2. For a non-security checksum, say so explicitly and bandit stops complaining:

not_security.py
hashlib.md5(data, usedforsecurity=False).hexdigest()
filtering
bandit -r . -ll        # only MEDIUM and HIGH severity
bandit -r . -iii       # only HIGH confidence
bandit -r . -ll -ii    # a sensible CI gate

B404 above is LOW severity — importing subprocess is not a vulnerability, just a hint to look closer. Gating CI on every finding trains people to ignore the tool; gating on medium-and-above keeps it credible.

False positives are expected, and should be justified in the code

Section titled “False positives are expected, and should be justified in the code”
nosec.py
password = "not-a-real-secret"  # nosec B105 - fixture value used only in tests

Always name the test id and give a reason. A bare # nosec disables everything on that line, including a genuine issue introduced later.

pyproject.toml
[tool.bandit]
exclude_dirs = ["tests", ".venv"]
skips = ["B101"]        # assert_used — asserts are the point in tests
pch.quizTag pch.quizDefaultTitle
  1. Bandit reported three HIGH severity issues in a file that flake8 and pylint both passed with only style complaints. What does that tell you?

    pch.quizShowAnswer

    B — the tools ask different questions; linters look at style and dead code, not at dangerous patterns — Measured B324, B605 and B602 as HIGH. pylint scored the same file 8.10/10. Running one tool and concluding the code is clean is the mistake.

  2. What is the fix for subprocess.call('ls ' + user_input, shell=True)?

    pch.quizShowAnswer

    B — pass a list of arguments and drop shell=True, so no shell is involved and there is nothing to inject into — subprocess.run(['ls', user_input]) executes the program directly. Escaping and validation are attempts to outguess a shell you do not need.

  3. Why should CI gate on medium-and-above rather than on every bandit finding?

    pch.quizShowAnswer

    B — low findings such as B404 are hints rather than vulnerabilities, and failing on everything trains people to ignore the tool — B404 simply notes that subprocess was imported. Keeping the gate credible is what makes people act on the findings that matter.

  4. What does a clean bandit run NOT tell you?

    pch.quizShowAnswer

    B — that the application's authorisation logic is correct — Bandit matches shapes in the AST. A broken ownership check or a logic flaw that leaks one user's data to another is invisible to it.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading