VaultLint is an offline static analyser for Solana programs. It parses your Rust source, applies five rules, and prints what it found. It runs locally, sends nothing anywhere, and needs no build of your program: the rules are hand-written and deterministic, with no network calls and no telemetry.
The rules are deliberately narrow. Every one of them was measured against open-source production programs before it shipped, and three of the five were rewritten or reversed when that measurement showed them to be wrong. The design constraint throughout is that a tool you stop trusting is a tool you stop running, so when a rule cannot tell a bug from a design decision without knowing the protocol, it stays quiet.
This is a linter, not a prover. A clean run means these five specific shapes were not found. It is not a statement about your program's security, and no page here should be read as one. What it does buy you is that five known classes of mistake cannot reach main unnoticed.
| ID | Rule | Severity | What it reports |
|---|---|---|---|
| VL001 | Unproven authority on initialization | Medium | An unvalidated authority account whose key goes into the seeds of an account this instruction creates. |
| VL002 | Missing owner check | High + Medium | Account data deserialised by hand while nothing proves which program owns the account. |
| VL003 | overflow-checks is not enabled | Medium + Low | The workspace root does not set overflow-checks, so release-mode arithmetic wraps silently. |
| VL004 | Non-canonical PDA bump | Medium | A seeds constraint validated against a bump the caller supplied, so the address is not pinned. |
| VL005 | Unchecked CPI to unknown program | Medium | A cross-program invocation whose program id is read off an account the caller chose. |
Severities and exit codes
VL002 is the only rule that can report High. At the default --fail-on high it is the only rule that fails a build; everything else reports and lets the run pass. Lower the threshold with --fail-on medium or --fail-on low when you want the rest to gate too.
Severity is not a guess at how much money is at stake — the tool has no way to judge how exposed a particular instruction is, and inventing a score would be guesswork dressed as analysis. It is a statement about evidence. Two rules vary it, and both do so for that reason. VL003 reports the missing build-profile line at Medium and each wrapping expression it then enables at Low. VL002 drops to Medium when the account arrives as a helper's bare &AccountInfo parameter, because the proof it would need is in the calling function and VaultLint reads one function at a time — a question the tool cannot answer should not fail your build by default.
What gets scanned
Applies to all five rules: test, benchmark and fuzz code is never scanned. A fuzz target that deserialises unvalidated account bytes is doing its job, not shipping a vulnerability, and a tool that reports it is training you to skim its output.
Four things are skipped, and each is identified by a declaration rather than by a name in a path:
- Files under a crate's own
tests/orbenches/directory — measured relative to the nearest ancestorCargo.toml, not to the repository root. - Crates whose
Cargo.tomlcarriespackage.metadata.cargo-fuzz. - Files declared as
#[cfg(test)] mod name;, resolved toname.rsorname/mod.rs. - Findings inside an inline
#[cfg(test)] mod { … }block.
The declaration-based approach matters more than it sounds. In Anchor's own repository the entire example corpus lives under a top-level tests/ directory — and those are real on-chain programs. A filter keyed on the directory name would have discarded the only confirmed true positive VL001 has on the whole corpus. That is the kind of obvious shortcut that turns into a silent hole in a security tool.
The #[cfg(test)] recogniser accepts #[cfg(all(test, …))] and rejects #[cfg(feature = "test")] and #[cfg(not(test))], so a crate feature that happens to be called "test" does not make your program invisible.
Every run reports how many files were skipped in its header. A tool that quietly drops findings does not deserve to be trusted, so the count is always in front of you.
Suppressing findings
Any finding can be silenced in place, with a reason:
// vaultlint:allow VL004 — bump comes from our own registry, not the caller
The comment is looked for on the finding's own line first, then upward through the contiguous block of comment lines, #[...] attribute lines (including multi-line ones) and blank lines directly above it. The walk stops at the first line that is none of those.
Searching the whole block rather than only the line above is not a convenience. On an Anchor account field the line above is always occupied — by the mandatory /// CHECK: doc comment, or by a six-line #[account(...)] attribute — so a single-line search would leave the two noisiest rules with no usable way to be silenced at all.
The rule id is matched on a word boundary, so VL0011 does not suppress VL001. A bare vaultlint:allow with no id is intentionally not honoured: switching off every rule at once should have to be spelled out.
Where these pages come from
Every finding the tool prints carries the address of its rule page, in terminal output, in JSON, and as the helpUri of each SARIF result — which is what makes the "learn more" link work on a GitHub Security alert. This index is also the destination of the rules table in the README and of vaultlint --help.
The source for all five rules is in the repository, under src/rules/. If a page and the code disagree, the code is right and the page is a bug — please open an issue.
Run all five checks on every PR.
VaultLint is an offline security linter for Solana and Anchor programs. One binary, no build of your program required.
Get VaultLint on crates.io