From ed5e559566c8c83290dfafd2904f76abcb0832c5 Mon Sep 17 00:00:00 2001 From: Leodanis Pozo Ramos Date: Mon, 2 Nov 2026 13:07:46 +0000 Subject: [PATCH] Sample code for: Hardening AI-Generated Python Code With Bandit and Ruff Co-Authored-By: Claude Sonnet 5.5 --- bandit-python/.github/workflows/security.yml | 32 +++++++ bandit-python/.pre-commit-config.yaml | 14 +++ bandit-python/README.md | 32 +++++++ bandit-python/fixed/helpers.py | 98 ++++++++++++++++++++ bandit-python/insecure/helpers.py | 85 +++++++++++++++++ bandit-python/pyproject.toml | 15 +++ bandit-python/requirements.txt | 5 + 7 files changed, 281 insertions(+) create mode 100644 bandit-python/.github/workflows/security.yml create mode 100644 bandit-python/.pre-commit-config.yaml create mode 100644 bandit-python/README.md create mode 100644 bandit-python/fixed/helpers.py create mode 100644 bandit-python/insecure/helpers.py create mode 100644 bandit-python/pyproject.toml create mode 100644 bandit-python/requirements.txt diff --git a/bandit-python/.github/workflows/security.yml b/bandit-python/.github/workflows/security.yml new file mode 100644 index 0000000000..81b7a43bc0 --- /dev/null +++ b/bandit-python/.github/workflows/security.yml @@ -0,0 +1,32 @@ +name: Security + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + bandit-and-ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + - name: Install scanners + run: python -m pip install "bandit[toml]==1.9.4" ruff==0.17.3 + - name: Ruff + run: ruff check . + - name: Bandit + if: success() || failure() + run: bandit -r . -c pyproject.toml + - name: Bandit on tests + if: success() || failure() + run: bandit -r tests -s B101 + - name: Reject blanket suppressions + if: success() || failure() + run: | + ! git grep -nE '#[[:space:]]*(nosec|noqa)[[:space:]]*(#|$)' -- '*.py' diff --git a/bandit-python/.pre-commit-config.yaml b/bandit-python/.pre-commit-config.yaml new file mode 100644 index 0000000000..37fa5045f0 --- /dev/null +++ b/bandit-python/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.17.3 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + - repo: https://github.com/PyCQA/bandit + rev: 1.9.4 + hooks: + - id: bandit + args: ["-c", "pyproject.toml"] + additional_dependencies: ["bandit[toml]"] + language_version: python3.14 diff --git a/bandit-python/README.md b/bandit-python/README.md new file mode 100644 index 0000000000..60bc51b3bc --- /dev/null +++ b/bandit-python/README.md @@ -0,0 +1,32 @@ +# Hardening AI-Generated Python Code With Bandit and Ruff + +Sample code for the Real Python guide on hardening AI-generated Python with Bandit and Ruff. + +- `insecure/helpers.py`: realistic "AI-generated" helpers with typical security issues +- `fixed/helpers.py`: the same helpers after fixes +- `pyproject.toml`: Bandit and Ruff settings (`bandit -c pyproject.toml`) +- `.pre-commit-config.yaml`: pre-commit hooks for Ruff and Bandit +- `.github/workflows/security.yml`: GitHub Actions workflow for your own repository + +## Setup + +Requires Python 3.11 or later. + +```sh +$ python -m venv venv +$ source venv/bin/activate +(venv) $ python -m pip install -r requirements.txt +``` + +## Run + +```sh +(venv) $ bandit -r insecure +(venv) $ ruff check --select S insecure +(venv) $ bandit -r fixed -c pyproject.toml +(venv) $ ruff check fixed +``` + +The `pyproject.toml` here has one extra line that the guide's file doesn't: `extend-exclude = ["insecure"]`. It keeps the `realpython/materials` lint from failing on the deliberately insecure module. Remove that line to see the `ruff-check` pre-commit hook fail on `insecure/helpers.py`, as in Step 4. + +The workflow in `.github/workflows/` runs only after you copy it into your own repository. diff --git a/bandit-python/fixed/helpers.py b/bandit-python/fixed/helpers.py new file mode 100644 index 0000000000..b75021205d --- /dev/null +++ b/bandit-python/fixed/helpers.py @@ -0,0 +1,98 @@ +import ast +import hashlib +import json +import logging +import os +import secrets +import sqlite3 +import subprocess +import tempfile + +import requests +import yaml + +logger = logging.getLogger(__name__) + +API_KEY = os.environ["API_KEY"] +DB_PASSWORD = os.environ["DB_PASSWORD"] + + +def run_report(filename): + subprocess.run(["sort", f"reports/{filename}"], check=True) # nosec B603 B607 # noqa: S603, S607 + + +def ping_host(host): + return subprocess.check_output(["ping", "-c", "1", host]) # nosec B603 B607 # noqa: S603, S607 + + +def make_token(): + return secrets.token_hex(16) + + +def hash_password(password, salt): + return hashlib.scrypt( + password.encode(), salt=salt, n=2**14, r=8, p=1 + ).hex() + + +def checksum(data): + return hashlib.md5(data, usedforsecurity=False).hexdigest() + + +def load_config(text): + return yaml.safe_load(text) + + +def parse_setting(text): + return ast.literal_eval(text) + + +def load_session(blob): + return json.loads(blob) + + +def fetch_status(url): + return requests.get(url, timeout=10).status_code + + +def fetch_profile(url): + return requests.get(url, timeout=10).json() + + +def write_cache(data): + with tempfile.NamedTemporaryFile("w", delete=False) as f: + f.write(data) + return f.name + + +def find_user(conn: sqlite3.Connection, name): + query = "SELECT * FROM users WHERE name = ?" + return conn.execute(query, (name,)).fetchall() + + +def safe_int(value): + try: + return int(value) + except ValueError: + logger.warning("Not an integer: %r", value) + return None + + +def parse_all(values): + results = [] + for value in values: + try: + results.append(int(value)) + except ValueError: + logger.warning("Skipping %r", value) + return results + + +def check_admin(user): + if not user.is_admin: + raise PermissionError("Admin only") + return True + + +def start_server(app): + app.run(host="127.0.0.1", port=8000) diff --git a/bandit-python/insecure/helpers.py b/bandit-python/insecure/helpers.py new file mode 100644 index 0000000000..d046995fa3 --- /dev/null +++ b/bandit-python/insecure/helpers.py @@ -0,0 +1,85 @@ +import hashlib +import pickle +import random +import subprocess +import tempfile +import yaml +import requests + +API_KEY = "sk-live-4f9a8b7c6d5e4f3a2b1c" +DB_PASSWORD = "admin123" + + +def run_report(filename): + subprocess.run(f"cat reports/{filename} | sort", shell=True) + + +def ping_host(host): + return subprocess.check_output("ping -c 1 " + host, shell=True) + + +def make_token(): + return "".join(random.choice("abcdef0123456789") for _ in range(32)) + + +def hash_password(password): + return hashlib.md5(password.encode()).hexdigest() + + +def load_config(text): + return yaml.load(text, Loader=yaml.Loader) + + +def parse_setting(text): + return eval(text) + + +def load_session(blob): + return pickle.loads(blob) + + +def fetch_status(url): + response = requests.get(url, verify=False) + return response.status_code + + +def fetch_profile(url): + return requests.get(url).json() + + +def write_cache(data): + path = tempfile.mktemp() + with open(path, "w") as f: + f.write(data) + return path + + +def find_user(conn, name): + query = "SELECT * FROM users WHERE name = '%s'" % name + return conn.execute(query).fetchall() + + +def safe_int(value): + try: + return int(value) + except Exception: + pass + + +def parse_all(values): + results = [] + for value in values: + try: + results.append(int(value)) + except: + continue + return results + + +def check_admin(user): + assert user.is_admin, "Admin only" + return True + + +def start_server(app): + app.run(host="0.0.0.0", port=8000) diff --git a/bandit-python/pyproject.toml b/bandit-python/pyproject.toml new file mode 100644 index 0000000000..a1de68e1cb --- /dev/null +++ b/bandit-python/pyproject.toml @@ -0,0 +1,15 @@ +[tool.bandit] +exclude_dirs = ["venv", ".venv", "tests"] +skips = ["B404"] + +[tool.ruff] +line-length = 79 +# Keeps the repo-wide CI lint from failing on the deliberately insecure +# module. Remove this line to see Ruff reject it in the pre-commit hook. +extend-exclude = ["insecure"] + +[tool.ruff.lint] +select = ["E", "F", "S", "BLE"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] diff --git a/bandit-python/requirements.txt b/bandit-python/requirements.txt new file mode 100644 index 0000000000..0236558b55 --- /dev/null +++ b/bandit-python/requirements.txt @@ -0,0 +1,5 @@ +bandit==1.9.4 +pre-commit==4.7.0 +PyYAML==6.0.3 +requests==2.34.2 +ruff==0.17.3