Skip to main content

BanditRS

The Python security scanner bandit, rewritten in pure Rust. Same tests, same options, same outputs, same exit codes — 17× to 87× faster, with 57% less memory.

CI Rust License Tests Parity Differential Version


Install

Fastest way to try it: uvx fetches, builds and runs BanditRS in one shot, installing nothing permanent. Here it scans the current directory:

uvx --from git+https://github.com/LePhilippeDucTai/BanditRS bandit -r .

To keep it around, install it as a tool — bandit, banditrs, bandit-baseline and bandit-config-generator all land on your PATH:

uv tool install git+https://github.com/LePhilippeDucTai/BanditRS
bandit -r .

Both commands build the Rust crate from source, so they need rustc ≥ 1.96 and take a couple of minutes the first time. To skip compiling altogether, grab the prebuilt wheel for your platform from the latest release — no Rust toolchain involved, and it works with every supported Python:

pip install https://github.com/LePhilippeDucTai/BanditRS/releases/download/v0.2.0/<wheel>
uvx --from <same-wheel-url> bandit -r .     # or run it on the fly, still without Rust

Platform wheel names, coexisting with the PyCQA bandit command, pre-commit, and building from source: §7.


Contents

  1. In one minute
  2. Side-by-side output comparison
  3. Benchmarks
  4. Why it's faster
  5. How parity is proven
  6. Remaining differences
  7. Usage and installation details
  8. Architecture
  9. Development
  10. License and credits

1. In one minute

bandit walks the AST of a Python file and runs ~75 security tests (command injection, unsafe deserialization, weak crypto, hardcoded passwords, vulnerable XML…). BanditRS does the same thing, using the Python parser from ruff and per-file parallel analysis.

The goal is not "a linter inspired by bandit": it is a drop-in replacement. You can substitute the binary in a pre-commit hook, a CI pipeline or a Makefile without changing a single option, and get byte-for-byte the same output (modulo the exceptions documented in §6).

bandit (Python) BanditRS
Language / runtime Python ≥ 3.10 + dependencies (PyYAML, stevedore, rich, GitPython…) Rust, one native binary, zero runtime
Test IDs 75 (42 plugins + 33 blacklists) the same 75
Long command-line options 27 the same 27
Output formats 9 (csv, custom, html, json, sarif, screen, txt, xml, yaml) the same 9
Executables bandit, bandit-baseline, bandit-config-generator the same 3
Exit codes 0 / 1 (issues) / 2 (usage error) identical
# nosec, .bandit, pyproject.toml, profiles, baseline
Parallelism no (one file after another) yes (rayon, one file per task)
Scanning the CPython 3.11 stdlib (672 files, 307k lines) 17.0 s — 75 MiB 0.20 s — 33 MiB

2. Side-by-side output comparison

The decisive test for a drop-in replacement is not "does it find the same bugs", it is "is the diff empty". Here is the answer, on a deliberately faulty file:

# app.py
import subprocess
import yaml


def load(path):
    return yaml.load(open(path).read())


def run(user_input):
    subprocess.call("grep " + user_input, shell=True)


PASSWORD = "hunter2"

(Real outputs, wrapped and elided with so they fit in two columns; the full diff follows.)

bandit -q app.py (Python)bandit -q app.py (BanditRS)
Run started:2026-09-09 09:53:52.239965+00:00

Test results:
>> Issue: [B404:blacklist] Consider possible security
   implications associated with the subprocess module.
   Severity: Low   Confidence: High
   CWE: CWE-78 (https://cwe.mitre.org/…/78.html)
   More Info: …/en/0.0.1.dev49/blacklists/…#b404-…
   Location: ./app.py:1:0
1	import subprocess
2	import yaml
3	

------------------------------------------------
>> Issue: [B506:yaml_load] Use of unsafe yaml load.
   Allows instantiation of arbitrary objects.
   Consider yaml.safe_load().
   Severity: Medium   Confidence: High
   CWE: CWE-20 (https://cwe.mitre.org/…/20.html)
   More Info: …/en/0.0.1.dev49/plugins/b506_yaml_load…
   Location: ./app.py:6:11
5	def load(path):
6	    return yaml.load(open(path).read())
7	
…
Total issues (by severity):
	Undefined: 0
	Low: 2
	Medium: 1
	High: 1
Run started:2026-09-09 09:53:52.273576+00:00

Test results:
>> Issue: [B404:blacklist] Consider possible security
   implications associated with the subprocess module.
   Severity: Low   Confidence: High
   CWE: CWE-78 (https://cwe.mitre.org/…/78.html)
   More Info: …/en/latest/blacklists/…#b404-…
   Location: ./app.py:1:0
1	import subprocess
2	import yaml
3	

------------------------------------------------
>> Issue: [B506:yaml_load] Use of unsafe yaml load.
   Allows instantiation of arbitrary objects.
   Consider yaml.safe_load().
   Severity: Medium   Confidence: High
   CWE: CWE-20 (https://cwe.mitre.org/…/20.html)
   More Info: …/en/latest/plugins/b506_yaml_load…
   Location: ./app.py:6:11
5	def load(path):
6	    return yaml.load(open(path).read())
7	
…
Total issues (by severity):
	Undefined: 0
	Low: 2
	Medium: 1
	High: 1

The diff -u of the two full outputs contains only ten changed lines — five pairs, four of which are exactly the same pattern:

@@ timestamp (volatile on both sides) @@
-Run started:2026-09-09 09:53:52.239965+00:00
+Run started:2026-09-09 09:53:52.273576+00:00
@@ version in the documentation URL (deviation #12) @@
-   More Info: …/en/0.0.1.dev49/blacklists/blacklist_imports.html#b404-import-subprocess
+   More Info: …/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess
-   More Info: …/en/0.0.1.dev49/plugins/b506_yaml_load.html
+   More Info: …/en/latest/plugins/b506_yaml_load.html
-   More Info: …/en/0.0.1.dev49/plugins/b602_subprocess_popen_with_shell_equals_true.html
+   More Info: …/en/latest/plugins/b602_subprocess_popen_with_shell_equals_true.html
-   More Info: …/en/0.0.1.dev49/plugins/b105_hardcoded_password_string.html
+   More Info: …/en/latest/plugins/b105_hardcoded_password_string.html

Everything else — issue ordering, severities, confidences, CWEs, line:column positions, code snippets, tabs, separator widths, closing metrics, exit code — is byte-for-byte identical. The only two differences are the timestamp (volatile by nature) and the version in the documentation URL (deviation #12, a deliberate choice).

2.1 The eight report formats, across all of examples/

The same exercise at the scale of the full fixture corpus (94 files), after neutralizing the volatile fields only (timestamp, tool version in the doc URL). Eight formats out of nine: screen is the same rendering as txt enriched with ANSI escape sequences, and is covered by tests/unit_formatters_screen.rs rather than by this comparison.

for fmt in json txt csv xml yaml html sarif custom; do
  bandit-python -r -q -f $fmt examples | normalise > py.$fmt
  banditrs      -r -q -f $fmt examples | normalise > rs.$fmt
  diff py.$fmt rs.$fmt
done
Format Report lines Diverging lines Verdict
json 13,293 0 identical
txt 6,073 0 identical
csv 598 0 identical
xml 1,792 0 identical
html 13,915 0 identical
custom 597 0 identical
sarif 23,973 4 the tool's own "version"/"semanticVersion" (0.0.1.dev49 vs 0.2.0)
yaml 11,589 164 PyYAML anchors/aliases (&id001/*id001) — deviation #11; content identical when re-read

Six formats out of eight are strictly identical. The other two diverge only in their representation, never in the data: SARIF announces its own version (which is the correct behaviour for two different tools), and YAML forgoes a PyYAML optimization based on Python object identity (id()), which Rust structurally does not have.

2.2 On real code: the bandit repository itself

$ # 69 files, 8,737 lines of code — bandit's own source, scanned by both tools
$ diff <(bandit-python -r -q -f json bandit/ | normalise) \
       <(banditrs      -r -q -f json bandit/ | normalise)
$ echo $?
0

Identical JSON report — same single issue found, same per-file metrics, same ordering.


3. Benchmarks

3.1 Protocol

No number in this section is an estimate. Everything is reproducible:

cargo build --release
scripts/bench_vs_python.sh -n 7                                       # corpus throughput
scripts/bench_vs_python.sh -n 15 examples/subprocess_shell.py examples/long_set.py
  • Machine: 4 vCPU container (Intel Xeon @ 2.10 GHz), 15 GiB RAM, Linux 6.18.44-fc-v24.
  • Reference: bandit 0.0.1.dev49 (PyCQA repository @ 1d3053d), CPython 3.11.15.
  • Candidate: banditrs 0.2.0, release profile (fat LTO, codegen-units = 1).
  • Measurement: median of N runs after one warm-up run, wall-clock time, -f json -q output redirected to /dev/null (we measure the analysis, not the terminal).
  • Memory: ru_maxrss from RUSAGE_CHILDREN, one dedicated measuring process per reading.

The full protocol, the non-regression thresholds and the callgrind profile are in docs/plan/benchmarks.md.

3.2 Throughput

Target Files Lines bandit (Python) BanditRS Factor
/usr/lib/python3.11 (CPython stdlib) 672 307,504 17.02 s 0.196 s 86.9×
examples/ (bandit fixtures) 94 9,534 6.497 s 0.341 s 19.0×
examples/long_set.py (the largest, 65 KiB) 1 7,279 6.247 s 0.359 s 17.4×
examples/subprocess_shell.py (a typical file) 1 60 0.197 s 0.006 s 32.3×

Cost per line on the stdlib: 55 µs/line on the Python side against 0.64 µs/line on the Rust side — i.e. 18 kloc/s against 1.57 Mloc/s.

Why is examples/ "only" 19× when the stdlib is 87×? Because examples/ is dominated by a single pathological file: long_set.py accounts for 6.25 s out of the corpus's 6.50 s on the Python side. Measuring examples/ therefore essentially means measuring one file — and parallelism does not help on a single file. The 19× is the pure sequential gain; the 87× on the stdlib is that same gain multiplied by parallelism. Both numbers are consistent, see §3.4.

3.3 Latency and memory

Metric Python BanditRS Delta
Latency on a 60-line file (the editor / pre-commit case) 197 ms 6 ms −97%
Peak RSS, stdlib scan 75.5 MiB 32.7 MiB −57%
Deployment size Python venv + ~8 dependencies one 5.1 MB binary

The 6 ms single-file latency is the most concrete day-to-day argument: under 10 ms, the analysis becomes fast enough to run on every save in an editor, which the ~200 ms of Python interpreter startup rules out.

3.4 Breaking the gain down: engine × parallelism

The overall factor mixes two independent effects. They can be separated by throttling rayon:

RAYON_NUM_THREADS Time (stdlib) Speedup vs Python Parallel speedup Efficiency
1 0.591 s 28.8× 1.00×
2 0.331 s 51.4× 1.79× 89%
4 0.176 s 96.7× 3.36× 84%

This series is a separate campaign from the one in §3.2 (median of 3 instead of 7), hence the 0.176 s here against 0.196 s there: run-to-run variance on this shared container is on the order of ±10%. What matters here is not the absolute value but the ratio between rows, all drawn from the same series.

The observed factor therefore factorizes cleanly:

$$ S_{\text{total}} ;=; \underbrace{28.8}{\text{sequential engine}} ;\times; \underbrace{3.36}{\text{4 cores}} ;\approx; 97 $$

Feeding the 4-core speedup into Amdahl's law recovers the parallelizable fraction of the work:

$$ S(p) = \frac{1}{(1-f) + \frac{f}{p}} \quad\Longrightarrow\quad f = \frac{1 - 1/S(4)}{1 - 1/4} = \frac{1 - 0.2976}{0.75} \approx \mathbf{0.94} $$

94% of the work is parallelizable; the remaining 6% is the filesystem walk, the serialization of the final report and the aggregation of metrics — all intrinsically sequential. The model therefore predicts a ceiling of $1/0.06 \approx 16.7\times$ for the parallel component alone: beyond a dozen cores, adding cores will bring almost nothing more, and the factor will stay governed by the sequential gain and by disk I/O. This is an extrapolation of a model from a single measurement point, to be confirmed on a machine with more cores.

3.5 Non-regression guardrail

Gains are worthless if they erode silently. benches/e2e.rs (criterion) measures 7 hot spots, and scripts/bench_regression.sh fails if any of them degrades by more than +10% relative to the committed baseline:

cargo bench --bench e2e            # HTML report in target/criterion/
scripts/bench_regression.sh        # comparison against the baseline, +10% threshold

4. Why it's faster

Three causes, in decreasing order of importance. None of them is "Rust is fast": they are architectural choices that the language merely makes possible.

1. The hot loop is no longer interpreted. bandit visits each AST node and, for each node, calls a list of plugin functions looked up in a Python dictionary, building a Context object wrapping a dict every time. The dominant cost is not the analysis, it is dispatch: hundreds of millions of interpreted dict lookups and function calls. On the Rust side the walker is a match on an enum of nodes, plugins are fns in a static table, and the Context is a typed struct borrowed from the tree — zero allocation per node.

Analogy. Python hands each node to a switchboard operator who looks up the right party in a directory, picks up the phone, and forwards the call. Rust wired the switchboard once and for all at compile time: each node lands directly on the right extension.

2. The parser. CPython's ast.parse is written in C and is fast, but it materializes one Python object per node (with a header, a reference count, an attribute dict). The ruff_python_parser / ruff_python_ast crates produce a compact tree of enums, with no indirection and no garbage collector.

3. Parallelism. Bandit's natural unit of work is the file, and files are independent. Rust has no GIL: rayon simply hands out one file per task. That is what the factor 3.36 in §3.4 measures — a factor the Python version cannot obtain without going multi-process (and therefore paying for a full interpreter per core, which would also explain its RSS).

An honest counter-example. The callgrind profile (benchmarks.md §6) shows that 51% of the current instruction budget goes into two functions that parity forces us to keep naive: NosecLines::for_range (30.8%, a line-by-line sweep of # nosec comments, quadratic in nesting depth) and the trojansource plugin (20.4%, ten search passes per line). Fixing them would probably buy another 25–30% — but any optimization that would change issue ordering, a position or an output string is forbidden without a validated entry in DEVIATIONS.md. Parity outranks speed.


5. How parity is proven

Relying on a single mode of proof means relying on its blind spots. BanditRS layers three of them, sharing no assumptions: one reads the specification, one queries the living oracle, one freezes the past.

Analogy. A new scale can be checked in three ways: by re-reading its manual (the test suite), by comparing it against a reference scale (the differential), and by re-weighing every morning the same reference weight kept in the drawer (the golden corpus). The third is the only one that still works when the reference scale is no longer in the room.

5.1 The Python test suite, ported test by test

The 273 tests in bandit/tests/ served as the acceptance specification. The porting rule: one Python test ⇒ one Rust test with the same name, in a mirror file.

Count
Reference Python tests 273
→ ported as-is 225
→ adapted (Python mocks replaced by real fixtures) 38
→ not portable (pure Python introspection: deepgetattr, meta_ast…) 10
Rust tests without a Python counterpart (argparse emulation — see §5.2) 1
Total Rust tests (67 unit + 284 integration) 351
Remaining #[ignore] tests 0

The line-by-line inventory is in docs/plan/test-inventory.md; the 10 non-portable ones are justified one by one (deviation #8).

5.2 Functional coverage: 75 test IDs out of 75

The set of test IDs is compared against the enumeration of the Python extension loader:

$ diff <(python -c "…extension_loader.MANAGER…") <(grep -ohE 'B[0-9]{3}' src/core/registry.rs src/core/blacklist.rs | sort -u)
$ echo $?
0
The 75 IDs (42 plugins + 33 blacklist entries)
Family IDs
B1xx — miscellaneous B101 B102 B103 B104 B105 B106 B107 B108 B110 B112 B113
B2xx — application B201 B202
B3xx — call blacklist B301 B302 B303 B304 B305 B306 B307 B308 B310 B311 B312 B313 B314 B315 B316 B317 B318 B319 B321 B323 B324
B4xx — import blacklist B401 B402 B403 B404 B405 B406 B407 B408 B409 B411 B412 B413 B415
B5xx — crypto & certificates B501 B502 B503 B504 B505 B506 B507 B508 B509
B6xx — injection B601 B602 B603 B604 B605 B606 B607 B608 B609 B610 B611 B612 B613 B614 B615
B7xx — templating B701 B702 B703 B704

The same goes for the CLI surface: the 27 long options of bandit --help are exactly those of BanditRS, including the mutual exclusions (-v/-q rejected with the same exit code 2).

bandit-baseline's argparse emulation is locked down byte-for-byte by a dedicated test — the only Rust test without a Python counterpart (argparse belongs to the standard library, so bandit's suite does not test it, and that is precisely why a discrepancy could slip in there):

Invocation Python BanditRS
bandit-baseline --help / -h help on stdout, exit 0 identical
bandit-baseline (no target) usage: + bandit-baseline: error: the following arguments are required: targets on stderr, exit 2 identical
bandit-baseline -f bogus . usage: + bandit-baseline: error: argument -f: invalid choice: 'bogus' …, exit 2 identical

5.3 Differential against Python bandit

scripts/diff_against_python.sh runs both binaries file by file, normalizes the volatile fields, and fails if a file diverges for a reason that is not on the allow-list in DEVIATIONS.md.

Corpus Files Identical JSON reports Unexpected divergences
examples/ (bandit fixtures) 94 94 0
/usr/lib/python3.11 (CPython stdlib) 672 672 0
bandit/ (bandit's own source code) 69 69 0
AST walk traces (order of visited nodes) 91 91 0
Aggregate reports over examples/, 8 formats 8 7 1 — yaml, PyYAML anchors (see §2.1)
$ scripts/diff_against_python.sh examples
walk traces: identical=91 different=0
json identical: examples          txt identical: examples
csv identical: examples           xml identical: examples
custom identical: examples        sarif identical: examples
html identical: examples          yaml DIFF: examples (PyYAML anchors — DEVIATIONS #11)
file-by-file JSON: identical=94 whitelisted=0 unexpected=0 total=94
diff_against_python.sh: zero unexpected diff

$ scripts/diff_against_python.sh --stdlib
file-by-file JSON: identical=672 whitelisted=0 unexpected=0 total=672
diff_against_python.sh: zero unexpected diff

Among the fields this normalization neutralizes, one case is worth showing, because the divergence is on Python's side:

# examples/tarfile_extractall.py, issue B202
-"issue_text": "… members were properly validated {'Other': <ast.List object at 0x7fc651c4e170>})."
+"issue_text": "… members were properly validated {'Other': <ast.List object at 0x0>})."

That message interpolates the repr() of an AST node, which contains the Python object's memory address: bandit's output is therefore not reproducible from one run to the next, nor comparable across two machines. BanditRS renders 0x0 to produce deterministic output (deviation #5).

5.4 Golden corpus: parity without Python

The two modes above assume a Python interpreter and an installed bandit. So that parity stays verifiable ad vitam — in CI, on a bare machine, five years from now — the reference outputs are committed:

tests/golden/          96 files, 3.7 MB
├── examples.{json,txt,csv,xml,yaml,html,sarif,custom}   aggregate reports over all of examples/
├── files/                                               86 JSON reports, one per fixture
└── normalize.rs                                         the normalization, in a single place
cargo test --test golden       # 9 tests, replays the whole corpus, no Python required

This test is what turns parity from a one-off observation into a regression invariant: any change to the engine that would move an issue, a column or a byte of a report makes the suite fail.


6. Remaining differences (deliberate)

The policy is explicit: strict parity on everything the test suite observes; bandit's known bugs are fixed only when no test depends on them. The typos in messages, the bad_calls/bad_imports inversion in legacy configuration conversion, the first-match ordering of blacklists, the "first hit" behaviour of nosec over a linerange, the absent ≠ None distinction in check_call_arg_value: all of it is reproduced identically, bugs included.

Here are the 15 deliberate deviations. The DEVIATIONS.md file gives the full rationale for each, backed by a test case.

# Deviation Visible where Nature
1 # nosec B101,B102 (comma without a space): Python only ignores the last id, BanditRS takes them all files with multi-id nosec 🐞 bug fixed
2 CSV formatter with no CWE (NOTSET): Python crashes (KeyError: 'link'), BanditRS writes an empty column -f csv 🐞 bug fixed
3 .bandit (INI): level/confidence/number converted to integers; the configfile key actually honoured --ini 🐞 bug fixed
4 discover_files no longer mutates the loaded configuration (Python accumulated exclusions across calls) repeated calls 🐞 bug fixed
5 Memory addresses (<ast.List object at 0x…>) rendered as 0x0; no RecursionError (iterative walker) B202, deeply nested files 🎯 determinism
6 No rich progress bar; --version shows the crate version; no running on Python x.y.z line console output 🎨 cosmetic
7 Positions of f-string constants: CPython ≥ 3.12 semantics by default -f json, columns ⚙️ BANDITRS_PYTHON_COMPAT=3.11
8 10 Python introspection tests (deepgetattr, meta_ast…) with no Rust equivalent test suite 📐 not portable
9 B703 / DeepAssignation: two edge cases where BanditRS is stricter (it does not reproduce two bugs in django_xss.py) ad-hoc files only 🐞 bug fixed
10 YAML: double-quoted (non-ASCII) scalars are not wrapped at 80 columns -f yaml, rare 🎨 cosmetic
11 YAML: no &id001/*id001 anchors/aliases (PyYAML deduplicates by Python object id()) -f yaml 🎨 cosmetic
12 more_info points at …/en/latest/… instead of the installed package version all formats 🎯 deliberate choice
13 Context::statement() has a real implementation (in Python the key is never written, so the property is always None) no plugin reads it 📐 testability
14 The "Using command line arg for selected targets" log is only emitted if the targets key is in the .bandit file -v --ini 🎨 cosmetic
15 bandit-baseline: "Got current commit: master" instead of "<sha> master" bandit-baseline 🎨 cosmetic

None of these deviations changes which issue is reported, at which line, with which severity — with the assumed exception of #9 (two B703 edge cases that no upstream fixture exercises) and #1 (a nosec syntax that Python clearly mishandles).


7. Usage and installation details

Installing

BanditRS ships as a Python package, so it drops into any environment that already has pip, uv or pipx — no Rust knowledge required to use it. The quick commands are at the top of this README; here is the full matrix.

Method Command Needs a Rust toolchain?
Prebuilt wheel (recommended) pip install https://github.com/LePhilippeDucTai/BanditRS/releases/download/v0.2.0/<wheel> No
Prebuilt wheel, no install uvx --from <same-wheel-url> bandit -r . No
uv, on the fly uvx --from git+https://github.com/LePhilippeDucTai/BanditRS bandit -r . Yes
uv, as a tool uv tool install git+https://github.com/LePhilippeDucTai/BanditRS Yes
From git pip install git+https://github.com/LePhilippeDucTai/BanditRS Yes
From source cargo build --release Yes

Why git+ needs Rust. pip install git+… and uvx --from git+… always build from source: the tool clones the repository and builds a wheel from the checkout on the spot. No prebuilt artifact is involved, so rustc ≥ 1.96 and cargo must be present, and the build takes a couple of minutes. That is inherent to the git+ syntax, not a limitation of this package. The prebuilt wheels attached to each release exist precisely to avoid it — pick the one matching your platform from the table below.

Why --from. The distribution is named banditrs, but the command you usually want is bandit. uvx assumes the two match, so plain uvx bandit would look for a different project; --from names the package explicitly and lets you pick any of the four commands it ships. uvx --from git+… banditrs -r . runs the same program under the non-conflicting alias.

Every release publishes one wheel per platform. Because the wheels contain native executables that do not link against libpython, each one is tagged py3-none-<platform> and works with every supported Python version:

Platform Architecture Wheel tag
Linux (glibc ≥ 2.17) x86-64 manylinux2014_x86_64
Linux (glibc ≥ 2.17) ARM64 manylinux2014_aarch64
macOS Intel macosx_*_x86_64
macOS Apple Silicon macosx_*_arm64
Windows x86-64 win_amd64
Windows ARM64 win_arm64

Installing gives you four commands:

bandit                     drop-in replacement for the PyCQA command
banditrs                   identical alias (see below)
bandit-baseline            scan against a git baseline
bandit-config-generator    emit a configuration profile

Coexisting with PyCQA bandit

The bandit command is a deliberate drop-in replacement, which means it occupies the same file name as the PyCQA package. If both are installed in the same environment, the last one installed wins — silently. The banditrs alias is there for exactly that case: it is byte-for-byte the same program under a name nothing else claims, so you can compare the two side by side, or migrate gradually, without uninstalling anything.

Usage

Every bandit invocation works as-is:

bandit -r my_project/                          # recursive scan
bandit -r my_project/ -f json -o report.json   # JSON report
bandit -r my_project/ -ll -ii                  # severity ≥ MEDIUM, confidence ≥ MEDIUM
bandit -r my_project/ -s B101,B601             # skip tests
bandit -r my_project/ -t B602                  # run a single test
bandit -r my_project/ -c bandit.yaml           # configuration profile
bandit -r my_project/ -b baseline.json         # show only new issues
bandit -r my_project/ --exit-zero              # never fail (report mode)
bandit --help                                  # all 27 options

The two companion tools too:

bandit-config-generator --show-defaults > bandit.yaml   # generate a full profile
bandit-baseline -r my_project/                          # diff against the parent commit (requires git)

From Python

The banditrs module is a thin locator/launcher around the same executables — the analysis is never reimplemented in Python:

import banditrs

banditrs.find_bandit_bin()                  # -> PosixPath('.../bin/bandit')
result = banditrs.run(["-r", "src/", "-f", "json"], capture_output=True, text=True)
result.returncode                           # non-zero when issues are found

python -m banditrs … is equivalent to calling bandit directly.

Replacing bandit in a CI pipeline

# .pre-commit-config.yaml — before
- repo: https://github.com/PyCQA/bandit
  rev: <tag>
  hooks: [{ id: bandit, args: ["-r", "src/"] }]

# after: same invocation, same output, same exit code
- repo: https://github.com/LePhilippeDucTai/BanditRS
  rev: v0.2.0
  hooks: [{ id: banditrs, args: ["-r", "src/"] }]

pre-commit builds the hook in its own isolated environment, so this route compiles from source and needs a Rust toolchain on the machine running the hook.

Building from source

rustup update stable          # rustc ≥ 1.96 required
git clone https://github.com/LePhilippeDucTai/BanditRS && cd BanditRS
cargo build --release
# → target/release/{bandit, banditrs, bandit-baseline, bandit-config-generator}

Development tooling

bandit --dump-walk examples/nosec.py     # AST walk trace (node order, contexts)
BANDITRS_PYTHON_COMPAT=3.11 bandit      # f-string positions the CPython 3.11 way

8. Architecture

This is not a line-by-line transcription: it is an idiomatic redesign (no dynamic dictionaries, no mutable global state, per-file parallelism) under an identical-output constraint.

src/
├── ast/         2,586 l.  ruff AST walk: walker, linerange, qualname, positions,
│                          literals, f-strings, debug trace
├── core/        4,654 l.  the engine: config, profiles, plugin registry, blacklists, tester,
│                          context, nosec, file discovery, metrics, parallel scan
├── plugins/     2,399 l.  the 42 plugins (32 files), messages and thresholds verbatim
├── formatters/  1,464 l.  the 9 output formats
├── cli/         1,579 l.  compatible argparse, bandit-baseline, bandit-config-generator
├── pycompat/    2,587 l.  exact emulation of the Python standard library wherever the output
│                          depends on it: PyYAML (80-column wrapping), configparser, format(),
│                          repr() of literals, encodings
└── source/        253 l.  file reading/decoding, line index, snippets
Python (bandit) Rust (BanditRS)
Source code 11,234 lines 15,961 lines
Tests 5,053 lines 5,725 lines

The pycompat/ module (2,587 lines, 16% of the code) deserves a word: it implements no security analysis at all. It exists only because bandit's output exposes implementation details of the Python standard library — PyYAML's line-wrapping algorithm, str.format() syntax, the repr() of literals, configparser semantics. This is the real price of byte-for-byte parity, and it is higher than that of the analysis engine itself.

Dependencies

ruff_python_{parser,ast} · ruff_{text_size,source_file} (parser) · rayon (parallelism) · regex · rustc-hash · indexmap · serde/serde_json · toml · saphyr-parser · encoding_rs · typed-arena.


9. Development

Quality gate

scripts/check.sh          # build + 351 tests + rustfmt + clippy -D warnings + benchmark compilation
scripts/check.sh fast     # without the benchmarks (development loop)

scripts/check.sh reproduces exactly the three jobs of the GitHub Actions workflow, kept but inert in .github/workflows/ci.yml.disabled. Exit 0 = the equivalent of a green CI.

Checking parity yourself

cargo test --test golden                      # replays the golden corpus — no Python required
scripts/diff_against_python.sh examples       # differential over the fixtures
scripts/diff_against_python.sh --stdlib       # differential over 672 stdlib files
scripts/gen_golden.sh                         # regenerate the golden corpus

The differential expects a reference Python bandit; by default /home/user/.pyenv-bandit/bin/bandit, overridable through the PY_BANDIT variable.

Internal documentation

File Contents
PLAN.md progress status, decisions, milestones, architecture, pitfalls encountered
DEVIATIONS.md the 15 deliberate deviations, justified one by one
docs/spec/core.md the exact semantics of the Python core (read before porting anything)
docs/spec/plugins.md the 42 plugins + blacklists: messages, regexes, defaults verbatim
docs/spec/cli_formatters_tests.md CLI, formatters, test suite as specification
docs/plan/benchmarks.md measurement protocol, thresholds, results, callgrind profile
docs/plan/test-inventory.md Python test ↔ Rust test mapping, line by line
docs/plan/README.md the parallel development plan (16 work packages), kept for the record

10. License and credits

Apache-2.0 — see LICENSE and NOTICE.

BanditRS is an independent reimplementation of bandit (PyCQA), from which it takes the entirety of its semantics, its messages, the examples/ fixtures and the test specifications. All credit for the design of the security rules goes to the original project.

The Python parser comes from ruff (Astral).

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

banditrs-0.2.0.tar.gz (576.3 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

banditrs-0.2.0-py3-none-win_arm64.whl (5.4 MB view details)

Uploaded Python 3Windows ARM64

banditrs-0.2.0-py3-none-win_amd64.whl (5.7 MB view details)

Uploaded Python 3Windows x86-64

banditrs-0.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (5.9 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ x86-64

banditrs-0.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (5.7 MB view details)

Uploaded Python 3manylinux: glibc 2.17+ ARM64

banditrs-0.2.0-py3-none-macosx_11_0_arm64.whl (5.4 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

banditrs-0.2.0-py3-none-macosx_10_12_x86_64.whl (5.7 MB view details)

Uploaded Python 3macOS 10.12+ x86-64

File details

Details for the file banditrs-0.2.0.tar.gz.

File metadata

  • Download URL: banditrs-0.2.0.tar.gz
  • Upload date:
  • Size: 576.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for banditrs-0.2.0.tar.gz
Algorithm Hash digest
SHA256 27f39f531fe2fccd1f5b16c539e685d8c51b6ef19d4d45618bf8270a45c6a6f2
MD5 bf93c39ddc29cc6307dd25717b020c97
BLAKE2b-256 769abbbef3f92de7fe1dff7a8ab2ec93a761d564d323edd9f2d346d30d7627a8

See more details on using hashes here.

Provenance

The following attestation bundles were made for banditrs-0.2.0.tar.gz:

Publisher: release.yml on LePhilippeDucTai/BanditRS

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file banditrs-0.2.0-py3-none-win_arm64.whl.

File metadata

  • Download URL: banditrs-0.2.0-py3-none-win_arm64.whl
  • Upload date:
  • Size: 5.4 MB
  • Tags: Python 3, Windows ARM64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for banditrs-0.2.0-py3-none-win_arm64.whl
Algorithm Hash digest
SHA256 c657a7da96ccefb9af5d69653c2e9c13ed69b49b441313b12689ab51a7ee1f84
MD5 f4f0c9699114307f728e4765b5482efb
BLAKE2b-256 4319cdf22caa4f5073f3cfeb0894e458afaeed78d92ce66439d434301b2ce043

See more details on using hashes here.

Provenance

The following attestation bundles were made for banditrs-0.2.0-py3-none-win_arm64.whl:

Publisher: release.yml on LePhilippeDucTai/BanditRS

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file banditrs-0.2.0-py3-none-win_amd64.whl.

File metadata

  • Download URL: banditrs-0.2.0-py3-none-win_amd64.whl
  • Upload date:
  • Size: 5.7 MB
  • Tags: Python 3, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for banditrs-0.2.0-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 8b69df4a2bc9433bb7f62bab6bc6c59d96e7b07afeeacc0772914238eb4fdd33
MD5 af7e103ab2d468555fe4377157b39574
BLAKE2b-256 9f415c9f907d7620d31ad1754d3398cf0933dcf77c43bcd6e55ec4d053b7beca

See more details on using hashes here.

Provenance

The following attestation bundles were made for banditrs-0.2.0-py3-none-win_amd64.whl:

Publisher: release.yml on LePhilippeDucTai/BanditRS

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file banditrs-0.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for banditrs-0.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 5b7f3c0622717d48f8ac13cbdfe58c985ede0055ee40245dd4423b83081d8dcf
MD5 1b56ad427ff0993fa6da4a9f06720a68
BLAKE2b-256 430cd0dc1c41aa7f76f6b6af0e6abfceb133a910fc8d992d79305f04d49ce5a1

See more details on using hashes here.

Provenance

The following attestation bundles were made for banditrs-0.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: release.yml on LePhilippeDucTai/BanditRS

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file banditrs-0.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for banditrs-0.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 854b6b5b28f228bcaea9192565e2aed4b8446aeedcc3e49fe2c4e699af2b8c15
MD5 d16c8b35ac9fb5abb41b91c977ed73db
BLAKE2b-256 c6f34651533eef1aa49b4dda196b201bffbdca74027cc10c56f7d546810c67c6

See more details on using hashes here.

Provenance

The following attestation bundles were made for banditrs-0.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: release.yml on LePhilippeDucTai/BanditRS

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file banditrs-0.2.0-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for banditrs-0.2.0-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 9c6c9a546592877cc74a864848e835d21dc3f577a7c405375c02957cd22a0a7f
MD5 d1d883474e71b4d566028f0b909cfc6f
BLAKE2b-256 419785c7594dabaf3a117e777183e8575a5a1c450e980c16d6f84e43a4501917

See more details on using hashes here.

Provenance

The following attestation bundles were made for banditrs-0.2.0-py3-none-macosx_11_0_arm64.whl:

Publisher: release.yml on LePhilippeDucTai/BanditRS

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file banditrs-0.2.0-py3-none-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for banditrs-0.2.0-py3-none-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 02c3885fc3ef61a14d47dfba83c3f572036d3c53f3146db276c95e03b807700a
MD5 0692f407c461993108039146fc626ac8
BLAKE2b-256 d571fd483ebec716b459f2d2bb1beaf2c6300d0f112a3b0c80b87019cd7f1d0d

See more details on using hashes here.

Provenance

The following attestation bundles were made for banditrs-0.2.0-py3-none-macosx_10_12_x86_64.whl:

Publisher: release.yml on LePhilippeDucTai/BanditRS

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.1

7 files

This release

0.2.0 This release

7 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page