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
S301flags 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.S602flagsshell=True. Fix that one first.S603flags asubprocesscall when an argument comes from a variable, such as a function parameter, and asks where that value comes from.S607flags 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.S608flags 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
Hide 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.