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.
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
- In one minute
- Side-by-side output comparison
- Benchmarks
- Why it's faster
- How parity is proven
- Remaining differences
- Usage and installation details
- Architecture
- Development
- 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) |
|---|---|
|
|
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,releaseprofile (fat LTO,codegen-units = 1). - Measurement: median of N runs after one warm-up run, wall-clock time,
-f json -qoutput redirected to/dev/null(we measure the analysis, not the terminal). - Memory:
ru_maxrssfromRUSAGE_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×? Becauseexamples/is dominated by a single pathological file:long_set.pyaccounts for 6.25 s out of the corpus's 6.50 s on the Python side. Measuringexamples/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+…anduvx --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, sorustc≥ 1.96 andcargomust be present, and the build takes a couple of minutes. That is inherent to thegit+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 namedbanditrs, but the command you usually want isbandit.uvxassumes the two match, so plainuvx banditwould look for a different project;--fromnames 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
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
27f39f531fe2fccd1f5b16c539e685d8c51b6ef19d4d45618bf8270a45c6a6f2
|
|
| MD5 |
bf93c39ddc29cc6307dd25717b020c97
|
|
| BLAKE2b-256 |
769abbbef3f92de7fe1dff7a8ab2ec93a761d564d323edd9f2d346d30d7627a8
|
Provenance
The following attestation bundles were made for banditrs-0.2.0.tar.gz:
Publisher:
release.yml on LePhilippeDucTai/BanditRS
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
banditrs-0.2.0.tar.gz -
Subject digest:
27f39f531fe2fccd1f5b16c539e685d8c51b6ef19d4d45618bf8270a45c6a6f2 - Sigstore transparency entry: 2773840757
- Sigstore integration time:
-
Permalink:
LePhilippeDucTai/BanditRS@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Branch / Tag:
refs/heads/main - Owner: https://github.com/LePhilippeDucTai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Trigger Event:
workflow_dispatch
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c657a7da96ccefb9af5d69653c2e9c13ed69b49b441313b12689ab51a7ee1f84
|
|
| MD5 |
f4f0c9699114307f728e4765b5482efb
|
|
| BLAKE2b-256 |
4319cdf22caa4f5073f3cfeb0894e458afaeed78d92ce66439d434301b2ce043
|
Provenance
The following attestation bundles were made for banditrs-0.2.0-py3-none-win_arm64.whl:
Publisher:
release.yml on LePhilippeDucTai/BanditRS
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
banditrs-0.2.0-py3-none-win_arm64.whl -
Subject digest:
c657a7da96ccefb9af5d69653c2e9c13ed69b49b441313b12689ab51a7ee1f84 - Sigstore transparency entry: 2773840901
- Sigstore integration time:
-
Permalink:
LePhilippeDucTai/BanditRS@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Branch / Tag:
refs/heads/main - Owner: https://github.com/LePhilippeDucTai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Trigger Event:
workflow_dispatch
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8b69df4a2bc9433bb7f62bab6bc6c59d96e7b07afeeacc0772914238eb4fdd33
|
|
| MD5 |
af7e103ab2d468555fe4377157b39574
|
|
| BLAKE2b-256 |
9f415c9f907d7620d31ad1754d3398cf0933dcf77c43bcd6e55ec4d053b7beca
|
Provenance
The following attestation bundles were made for banditrs-0.2.0-py3-none-win_amd64.whl:
Publisher:
release.yml on LePhilippeDucTai/BanditRS
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
banditrs-0.2.0-py3-none-win_amd64.whl -
Subject digest:
8b69df4a2bc9433bb7f62bab6bc6c59d96e7b07afeeacc0772914238eb4fdd33 - Sigstore transparency entry: 2773840777
- Sigstore integration time:
-
Permalink:
LePhilippeDucTai/BanditRS@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Branch / Tag:
refs/heads/main - Owner: https://github.com/LePhilippeDucTai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file banditrs-0.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: banditrs-0.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 5.9 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5b7f3c0622717d48f8ac13cbdfe58c985ede0055ee40245dd4423b83081d8dcf
|
|
| MD5 |
1b56ad427ff0993fa6da4a9f06720a68
|
|
| BLAKE2b-256 |
430cd0dc1c41aa7f76f6b6af0e6abfceb133a910fc8d992d79305f04d49ce5a1
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
banditrs-0.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl -
Subject digest:
5b7f3c0622717d48f8ac13cbdfe58c985ede0055ee40245dd4423b83081d8dcf - Sigstore transparency entry: 2773840800
- Sigstore integration time:
-
Permalink:
LePhilippeDucTai/BanditRS@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Branch / Tag:
refs/heads/main - Owner: https://github.com/LePhilippeDucTai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file banditrs-0.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: banditrs-0.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 5.7 MB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
854b6b5b28f228bcaea9192565e2aed4b8446aeedcc3e49fe2c4e699af2b8c15
|
|
| MD5 |
d16c8b35ac9fb5abb41b91c977ed73db
|
|
| BLAKE2b-256 |
c6f34651533eef1aa49b4dda196b201bffbdca74027cc10c56f7d546810c67c6
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
banditrs-0.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl -
Subject digest:
854b6b5b28f228bcaea9192565e2aed4b8446aeedcc3e49fe2c4e699af2b8c15 - Sigstore transparency entry: 2773840828
- Sigstore integration time:
-
Permalink:
LePhilippeDucTai/BanditRS@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Branch / Tag:
refs/heads/main - Owner: https://github.com/LePhilippeDucTai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file banditrs-0.2.0-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: banditrs-0.2.0-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 5.4 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9c6c9a546592877cc74a864848e835d21dc3f577a7c405375c02957cd22a0a7f
|
|
| MD5 |
d1d883474e71b4d566028f0b909cfc6f
|
|
| BLAKE2b-256 |
419785c7594dabaf3a117e777183e8575a5a1c450e980c16d6f84e43a4501917
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
banditrs-0.2.0-py3-none-macosx_11_0_arm64.whl -
Subject digest:
9c6c9a546592877cc74a864848e835d21dc3f577a7c405375c02957cd22a0a7f - Sigstore transparency entry: 2773840884
- Sigstore integration time:
-
Permalink:
LePhilippeDucTai/BanditRS@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Branch / Tag:
refs/heads/main - Owner: https://github.com/LePhilippeDucTai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file banditrs-0.2.0-py3-none-macosx_10_12_x86_64.whl.
File metadata
- Download URL: banditrs-0.2.0-py3-none-macosx_10_12_x86_64.whl
- Upload date:
- Size: 5.7 MB
- Tags: Python 3, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
02c3885fc3ef61a14d47dfba83c3f572036d3c53f3146db276c95e03b807700a
|
|
| MD5 |
0692f407c461993108039146fc626ac8
|
|
| BLAKE2b-256 |
d571fd483ebec716b459f2d2bb1beaf2c6300d0f112a3b0c80b87019cd7f1d0d
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
banditrs-0.2.0-py3-none-macosx_10_12_x86_64.whl -
Subject digest:
02c3885fc3ef61a14d47dfba83c3f572036d3c53f3146db276c95e03b807700a - Sigstore transparency entry: 2773840853
- Sigstore integration time:
-
Permalink:
LePhilippeDucTai/BanditRS@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Branch / Tag:
refs/heads/main - Owner: https://github.com/LePhilippeDucTai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@91568c4a1f5dff6d5d95f3e15ff32631ddb8bf6b -
Trigger Event:
workflow_dispatch
-
Statement type: