Skip to content
← Back to explainers

Python explainer

Use Ruff's Security Rules Without the Noise

About 4 min to read. Allow extra time to work through examples.

Sort the findings from Ruff's S rules into real bugs and noise. Fix the real ones with the tools from this track, and silence the rest with a reason you can defend.

This article uses Python. You can make it your practice mode to browse related content across the site.

You add Ruff's security rules to a project and run ruff check --select S --output-format concise. It reports 40 findings. Here are seven of them, from one file:

app/jobs.py:9:12: S301 `pickle` and modules that wrap it can be unsafe when used to deserialize untrusted data, possible security issue
app/jobs.py:13:20: S311 Standard pseudo-random generators are not suitable for cryptographic purposes
app/jobs.py:17:12: S324 Probable use of insecure hash functions in `hashlib`: `md5`
app/jobs.py:25:27: S607 Starting a process with a partial executable path
app/jobs.py:31:5: S110 `try`-`except`-`pass` detected, consider logging the exception
app/jobs.py:36:5: S101 Use of `assert` detected
app/jobs.py:41:12: S608 Possible SQL injection vector through string-based query construction

The S rules come from Bandit, an older security linter. Each rule matches a pattern in your code. Ruff cannot see where a value comes from, so it cannot tell a filename a user typed from one you wrote yourself. Read each finding as a question. Answer it, then fix the code, or keep the code and write down why.

Findings this track has already covered

  • S301 flags loading a pickle. Ask who can write those bytes. If anyone other than your own program can, switch to JSON and check the shape, as in Load Outside Data Without Running Its Code.
  • S602 flags shell=True. Fix that one first. S603 flags a subprocess call when an argument comes from a variable, such as a function parameter, and asks where that value comes from. S607 flags a program named without its full path, such as "git". Run a Command Without a Shell and Keep User Input Out of Commands and SQL show the fixes.
  • S608 flags SQL built from strings. Building SQL that way allows SQL injection, where user input changes the query itself. Pass the values to the driver as parameters, so the database treats them as data and never as SQL.

Four findings with a short fix

S311 flags the random module. It suits simulations, not secrets. Use secrets for anything a stranger must not guess:

import secrets


def new_reset_token() -> str:
    return secrets.token_urlsafe(32)  # 32 random bytes, 43 characters

S324 flags MD5 and SHA-1. If the hash is a plain checksum with no security role, say so with hashlib.md5(data, usedforsecurity=False), and Ruff stops flagging it. A cache key built from uploaded files does have a security role, because an attacker can make two files with the same MD5. Passwords need a password hash, such as hashlib.scrypt. To check that a message came from someone who holds a key, compute an HMAC and compare it with hmac.compare_digest:

import hmac


def signature_matches(key: bytes, message: bytes, supplied_tag: bytes) -> bool:
    expected_tag = hmac.digest(key, message, "sha256")
    return hmac.compare_digest(supplied_tag, expected_tag)

== may stop as soon as it finds a difference. The time it takes can then tell an attacker how much of a forged tag was right. compare_digest does not stop early. Ruff does not flag == here, so this check is up to you.

S110 flags except Exception: pass, which hides every error, including the ones you did not expect. Catch only the one you expect:

import contextlib
from pathlib import Path


def remove_temp(path: Path) -> None:
    with contextlib.suppress(FileNotFoundError):
        path.unlink()

S101 flags assert. Python skips every assert when it runs with the -O flag, so an assert that checks input stops checking in production. Use if amount <= 0: raise ValueError(...) in application code. In tests, assert is how pytest checks results, so allow it there.

Silence a finding with a reason

Turn the rules on in pyproject.toml, and allow the test-only patterns in your tests folder. DTZ adds checks for datetimes without a time zone, such as datetime.now(). Fix those with datetime.now(UTC), importing UTC from datetime.

[tool.ruff.lint]
extend-select = ["S", "DTZ"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "S105", "S106", "S107", "S311"]

S105 to S107 flag strings that look like passwords. The ignore allows fake passwords in tests, but Ruff then stays quiet about a real one too. For one line in application code, add # noqa: S607 at the end of the line. Put a comment above it that says why the finding does not apply.

A clean run means none of the enabled patterns matched. It does not show that your code checks outside data at each trust boundary. Keep the tests that feed bad input to each boundary, and have someone read the code there.

Check your understanding Ruff reports S607 on subprocess.run(["git", "status"], check=True). Is this an injection bug? Show answer

No. Every argument is a literal string and there is no shell, so no user input reaches the command. The finding is about which git runs. The system looks for git in the folders listed in the PATH environment variable, so whoever controls PATH picks the program. If your service sets its own environment, keep the call and add a noqa with that reason. Otherwise pass the full path, such as /usr/bin/git.

Going further

The Ruff rule reference explains each S rule. The secrets and hmac documentation cover tokens and signatures.

step 10 of 16

Config, Logs and Untrusted Input

Up next Log from a Library the Right Way