
Dependency-free static analyzer for zk circuit soundness bugs in o1js/Mina zkApps and Noir circuits
Community package:
o1js-scanis listed in the official o1js Community Packages directory.
Latest: 0.20.0 — the analyzer now reads contracts that
extends TokenContract. Until this release the contract gate matchedSmartContractalone, so every fungible token, NFT collection and AMM pool in the ecosystem scanned as "no findings". If you scanned a token contract before 0.20.0, scan it again. See CHANGELOG.
A fast, dependency-free static analyzer for zk circuit soundness bugs in:
.ts / .js) — Kimchi circuits from @method bodies.nr) — Aztec's Rust-like ZK DSL (including aztec-nr-shaped patterns)The security-critical bugs usually aren't in the proving system — they're in the
application's own constraints: witnesses the prover controls but the circuit
never binds. o1js-scan is the under-constrained-signal scanner for Circom's
cousins in the Mina and Noir ecosystems.
See also: gnark-safety (gnark circuits), vk-guard (verification-key regression).
pip install o1js-scan
# or: pipx install o1js-scan
# or: npm install -D o1js-scan
o1js-scan path/to/zkapp # o1js + Noir (auto)
noir-scan path/to/circuits # same binary — Noir-friendly alias
noir-scan . --lang noir --fail-on high --sarif noir.sarif
Given a vault whose withdraw amount is a prover-controlled witness that is
never bound to on-chain state:
$ o1js-scan examples/vulnerable_vault.ts --include-examples
LOW O1JS_UNCONSTRAINED_RECIPIENT vulnerable_vault.ts:23 fn=withdraw Recipient `to` is prover-chosen in `withdraw`
HIGH O1JS_UNCONSTRAINED_WITNESS vulnerable_vault.ts:23 fn=withdraw Unconstrained witness `amount` flows to send_amount in `withdraw`
o1js-scan: 2 finding(s) [1 high, 1 low] in 1 of 1 file(s) — fails (--fail-on high)
$ echo $?
1
--include-examples is needed here only because the demo file lives under
examples/, which the path classifier downgrades by default so that a repo's
own sample code cannot fail its build. The same contract in your src/ reports
HIGH with no flag.
The HIGH finding is the drainable bug. The fixed contract
(examples/safe_vault.ts) drops it and exits 0, keeping only the informational
LOW on the prover-chosen recipient:
$ o1js-scan examples/safe_vault.ts --include-examples
LOW O1JS_UNCONSTRAINED_RECIPIENT safe_vault.ts:23 fn=withdraw Recipient `to` is prover-chosen in `withdraw`
o1js-scan: 1 finding(s) [1 low] in 1 of 1 file(s) — passes (--fail-on high)
$ echo $?
0
See examples/ for the o1js and Noir vulnerable/fixed pairs.
pip install o1js-scan
For an isolated global CLI install, use pipx:
pipx install o1js-scan
For Node/npm-based Noir, Aztec, or o1js app repositories, install the npm wrapper:
npm install -D o1js-scan
npx noir-scan . --lang noir --fail-on high
The npm package is a thin wrapper around the same Python analyzer and requires
Python 3.8+ on PATH (python3 or python). Set O1JS_SCAN_PYTHON to choose a
specific interpreter.
Or from source:
git clone https://github.com/auditinfra-io/o1js-scan
cd o1js-scan
pip install -e .
No third-party Python dependencies. Python 3.8+. The noir-scan console script is
installed alongside o1js-scan (same entry point), including through the npm
wrapper.
# scan a directory (recursively; skips node_modules, target/, .git, …)
o1js-scan path/to/project
# Noir-only / o1js-only
noir-scan circuits --lang noir
o1js-scan src --lang o1js
# scan a single file
o1js-scan src/MyContract.ts
noir-scan src/main.nr
# machine-readable output for CI
o1js-scan src --json
# SARIF 2.1.0 for GitHub code scanning (writes o1js-scan.sarif by default)
o1js-scan src --sarif
noir-scan . --lang noir --sarif noir.sarif
# choose which severity fails CI (critical|high|medium|low|none; default high)
o1js-scan src --fail-on medium
# progressive/power-user gate (equivalent to --fail-on medium)
o1js-scan src --strict
# test code is excluded by default (both backends); opt back in
o1js-scan src --include-tests
# example code is downgraded to LOW by default; keep original severity
o1js-scan src --include-examples
o1js-scan --version
Exit code is 1 when a finding at or above the --fail-on level (default
high) is present and 0 otherwise — so you can drop it straight into CI.
With the default, a low/medium finding (including the informational recipient
rule below) does not fail the build; use --fail-on none to only report,
or --strict (a shorthand for --fail-on medium) to gate more strictly while
still treating low-severity findings as advisory. The two options are mutually
exclusive so CI configuration cannot be ambiguous. A missing scan path exits 2
with an error on stderr, so a typo can't silently pass CI as a clean run. Every run
prints a one-line summary (counts by severity and the gate verdict) to stderr.
Test code is excluded by default — both backends. Tests deliberately build invalid values and bad transactions to prove the asserts reject them, so a finding there is the point of the test rather than a circuit bug. A file counts as test code when:
*.test.ts / *.spec.ts (and the .js/.jsx/.tsx/.mjs
/.cjs variants), or *_test.nr / test_*.nr;test/, tests/, __tests__/, spec/ or __mocks__/
directory;#[test] / #[test(...)]
attribute, or sits inside a mod test { … } / mod tests { … } block —
block-scoped, so a test module at the foot of a production file does not
silence the rest of it.Pass --include-tests to report them.
Example code is downgraded, not dropped. A finding in an examples/ or
example/ directory, or in a file named *.eg.ts (.nr and the other JS/TS
extensions too), is lowered to LOW with a note — still reported, no longer
able to fail a build. Example code is deliberately simplified, and flagging a
framework's own examples as vulnerabilities is noise; but it is copied into
production far more often than test code is, which is why it is downgraded
rather than hidden. Pass --include-examples to keep the original severity.