Ruff Won't Catch Security Bugs Unless You Ask It To

Ruff's default ruleset covers style and correctness — not security. Here's what to add to Ruff, and what neither Ruff nor Bandit covers in AI-generated Python.

Copper Sun Brass Team · · 6 min read
comparisonsecurityoss-core

Ruff replaced flake8, isort, pyupgrade, and half a dozen other tools in most Python projects. It’s fast, easy to configure, and covers an enormous surface area. What it doesn’t cover by default: security.

Run ruff check . on a fresh project and you get style checks, import ordering, unused variables, and a set of correctness rules. You don’t get SQL injection detection. You don’t get hardcoded secret scanning. You don’t get command injection checks. Those rules exist in Ruff — they’re just not on unless you turn them on.

What Ruff’s Default Ruleset Covers

BrassCoders names Ruff’s default scope precisely because the gap is easy to miss. Ruff’s default enabled rules include E (pycodestyle errors), W (pycodestyle warnings), and F (Pyflakes) — style enforcement, import cleanup, and correctness checks like undefined names and unused imports. These are the rules that catch a misspelled variable name, an f-string that references a nonexistent variable, or a wildcard import that pollutes the namespace.

Security rules live under a different prefix. Ruff’s S ruleset is a port of flake8-bandit, which is itself derived from Bandit — the Python security linter from PyCQA. S608 checks for SQL injection via string formatting. S603 checks for subprocess calls without shell-injection protection. S506 checks for yaml.load without a safe loader. None of these are in Ruff’s default select. A developer who installed Ruff to modernize their linting stack and left the configuration at defaults has zero security coverage from Ruff, regardless of how many other rules pass.

The Ruff documentation lists every rule and its default status. The S-prefix rules are all opt-in.

How to Enable Security Checks in Ruff

BrassCoders bundles Bandit’s full ruleset internally — if you want Bandit-equivalent coverage inside your existing Ruff pipeline, you can enable it with a two-line config change. In ruff.toml:

[lint]
select = ["E", "F", "W", "S"]

Or on the command line: ruff check --select S .

The S ruleset covers the most common security anti-patterns: SQL injection, command injection, unsafe deserialization, hardcoded secrets, weak cryptography, and a handful of others that Bandit has been catching for years. If you’re running Ruff without S rules and you have Python security concerns, enabling them is the right first step.

One caveat worth naming: Ruff’s S rules are a subset of Bandit’s full ruleset. Bandit standalone runs a broader set of checks and gets updated as new patterns emerge. For most codebases, Ruff’s S coverage is sufficient for the common cases. For teams who want Bandit’s full coverage, BrassCoders bundles Bandit as one of its 12 scanners — running brasscoders scan replaces the separate Bandit invocation and adds the additional scanners on top.

What Even --select S Won’t Find

Here’s where Ruff and Bandit share a gap. In BrassCoders’s published benchmark against 12 AI-generated Python files with planted bugs, Bandit caught 6 of 12. The 6 it caught were all security bugs: SQL injection, command injection, unsafe deserialization, hardcoded credentials. The 6 it missed included 4 performance anti-patterns and 2 correctness bugs.

The performance anti-patterns are the signal. AI coding assistants produce a specific set of performance bugs that don’t appear often in human-written code:

  • O(N²) string concatenation — building a string inside a loop with += instead of join()
  • list.insert(0, item) for prepending — O(N) per call, should be collections.deque
  • Nested loops used as joins on large datasets — where a set lookup or dictionary would run in O(1)
  • Unbounded polling intervals — while True: check_something() with no sleep or backoff

These patterns appear in AI-generated code because the model optimizes for the happy path. The code works on small inputs. It satisfies the prompt. The performance implication only surfaces at scale, and the model doesn’t model scale unless the prompt asks for it explicitly.

Neither Ruff nor Bandit has rules for these patterns. They weren’t a meaningful failure mode in human-written code. BrassCoders caught 11 of 12 in the same benchmark — the 5 additional bugs beyond Bandit’s 6 included all 4 AI-coder performance anti-patterns and one phantom import.

The phantom import is its own category. AI coding assistants occasionally reference libraries that don’t exist, or reference real library names with invented APIs. A linter that checks only code style can’t detect a package that’s hallucinated — it would need to verify the import against a registry or a known package list. BrassCoders’s AI-pattern scanner does this. Ruff’s F401 (unused import) catches a different case: a package that’s imported but never used in the file. That’s not the same as a package that’s used in the file but doesn’t exist on PyPI.

The Full Stack: Ruff, BrassCoders, and Where They Divide

The right mental model: Ruff and BrassCoders cover different layers of the same codebase.

Ruff runs in your editor and pre-commit hook. It’s synchronous and near-instant. Its job is to enforce style, catch trivial errors before they reach review, and keep imports clean. Enable the S ruleset and it adds Bandit-equivalent security checks to that same fast pass.

BrassCoders runs in CI, after the commit. It takes 20–60 seconds on a typical project. It runs 12 scanners — including Bandit’s full ruleset — and applies the additional AI-pattern, performance, and secret-format detectors on top. Its output lands in .brass/ai_instructions.yaml, where Claude Code or Cursor picks it up and triages each finding.

They’re not competing for the same slot. Ruff’s fast feedback loop catches the obvious stuff while you’re writing it. BrassCoders’s CI gate catches what slips through — the performance anti-pattern that only shows under load, the phantom import that passes a style check, the hardcoded token that an AI assistant added as a placeholder and you forgot to replace.

Both belong in the pipeline. Neither replaces the other.

pip install brasscoders
brasscoders scan .

The scan runs offline by default. Nothing leaves your machine. The AI-pattern rules, Bandit integration, and secret detectors are all in the free OSS core — no subscription needed to close the gap Ruff leaves open.

If you’re already running Ruff, add select = ["S"] today and add brasscoders scan to your CI pipeline. The setup takes about 30 minutes end to end. The benchmark data on what each tool covers — and what each misses — is reproducible against the published corpus.

Frequently Asked Questions

Does Ruff include security checks?

Yes, but they're opt-in. Ruff's S ruleset (ported from flake8-bandit) covers a subset of Bandit's security checks, but it is not enabled by default. A plain `ruff check .` runs style and correctness rules only. Add `--select S` or set `select = ["S"]` in ruff.toml to activate them.

Can I use Ruff's S rules instead of Bandit?

Ruff's S rules cover a large portion of Bandit's checks, but Bandit standalone runs a broader ruleset. BrassCoders bundles Bandit as one of its 12 scanners — so running brasscoders scan gives you Bandit's full coverage plus AI-specific performance, secret, and phantom-import detection in one command, and removes the need for a separate Bandit invocation.

What AI-generated bugs does Ruff miss?

Performance anti-patterns AI coding assistants produce: O(N²) string concatenation in loops, list.insert(0) used for prepending in a loop, unbounded polling intervals, and nested-loop joins that should be set operations. Ruff has no rules for these patterns because they weren't a meaningful failure mode before AI-generated code became common.

Does BrassCoders replace Ruff?

No. They cover different ground. Ruff handles imports, style, unused variables, and correctness rules — run it in your editor and pre-commit hook. BrassCoders scans for security issues, secrets, AI-specific performance bugs, and phantom imports — run it in CI. The two tools complement each other; neither replaces the other.

How do I add security checks to an existing Ruff setup?

Two options: add `select = ["S"]` to ruff.toml to enable Bandit-equivalent checks inside Ruff's existing pipeline; or add brasscoders scan to CI to get Bandit's full rule coverage plus AI-specific patterns. Both can run alongside Ruff without conflict.

What's the Ruff equivalent of Bandit's B608 SQL injection rule?

Ruff maps Bandit's B608 to S608. Enable it with `--select S608` or include S in your ruff.toml select list. BrassCoders runs the full Bandit ruleset internally, including B608, and reports SQL injection findings at HIGH severity in the .brass/ai_instructions.yaml output.