sentio
AST-based security scanner for Solana/Anchor programs.
sentio scans Rust source files for common Solana vulnerability patterns using syn — Rust's macro-safe AST parser. It understands Anchor account constraints, instruction logic, and CPI call graphs to produce high-signal findings with minimal false positives.
Quick Start
# Install
# Scan your program
# Scan with JSON output (for CI pipelines)
# Run only one specific rule
# List all available rules
CLI Reference
sentio <COMMAND>
Commands:
scan Scan a Solana program directory or file for vulnerabilities
rules Manage and inspect the built-in rule set
sentio scan [OPTIONS] [PATH]
Arguments:
[PATH] Directory or .rs file to scan [default: .]
Options:
--format <FORMAT> Output format: human (default) | json
--rule <RULE_ID> Run only a specific rule, e.g. --rule SW003
--include-tests Include test files (excluded by default to reduce noise)
-h, --help Print help
sentio rules list Print all rule IDs and titles
Exit codes
| Code | Meaning |
|---|---|
0 |
No findings |
1 |
One or more findings |
2 |
Parse error in one or more files |
Example Output
$ sentio scan ./programs/my-program
==============FINDING 1: SW001 Missing signer check==============
Severity: critical
Location: src/instructions/update.rs:14:1
Rule:
Detects AccountInfo or UncheckedAccount fields whose names suggest an
authority role but have no signer constraint and no is_signer guard.
Matched Because:
Account `authority` appears to be an authority but has no signer constraint
and no is_signer guard; an attacker can pass an unsigned account.
Source:
12| pub vault: Account<'info, Vault>,
13|
>14| #[account(mut)]
| ^
15| pub authority: AccountInfo<'info>,
16|
Guidance:
Use Signer<'info> as the field type, add #[account(signer)], or add
require!(account.is_signer, ...) in the instruction handler.
==============FINDING 2: SW003 Arbitrary CPI target==============
Severity: critical
Location: src/instructions/transfer.rs:29:5
Rule:
Detects CPI calls where no key or program ID check precedes the invocation,
allowing an attacker to supply a malicious program as the CPI target.
Matched Because:
CPI call `invoke` in `handler` has no preceding program key validation.
Source:
27| let ix = build_instruction(&ctx);
28|
>29| invoke(&ix, &[ctx.accounts.target_program.to_account_info()])?;
| ^
30| Ok(())
31|
Guidance:
Add require!(program.key() == expected::ID, ...) before the CPI, or use
Program<'info, T> to enforce program ID validation at the account level.
-------- Summary --------
Total findings: 2
Critical: 2
High: 0
Medium: 0
Low: 0
By rule:
1 SW001 Missing signer check
1 SW003 Arbitrary CPI target
Rules
| ID | Title | Severity | What it catches |
|---|---|---|---|
| SW001 | Missing signer check | Critical | AccountInfo/UncheckedAccount named as authority with no #[account(signer)] and no is_signer guard |
| SW002 | Missing owner check | Critical | AccountInfo/UncheckedAccount with no owner or address constraint and no owner guard in handler |
| SW003 | Arbitrary CPI target | Critical | Raw invoke/invoke_signed calls with no preceding program key validation |
| SW008 | Missing post-CPI reload | High | Account written after a CPI that may have mutated it, without an intervening reload() |
| SW011 | AccountInfo as data account | Medium | AccountInfo used where a typed Account<'info, T> is needed (init/has_one/seeds constraints present) |
| SW012 | Missing seeds + bump on PDA | High | PDA accounts with seeds but no bump, skipping bump verification |
| SW016 | init_if_needed usage | Medium | init_if_needed accounts that can be silently re-initialized, resetting state |
| SW018 | Missing realloc::zero | Medium | realloc without realloc::zero = true, leaving stale data in reallocated memory |
| SW020 | AccountInfo as CPI program | Medium | AccountInfo used as a CPI program account instead of typed Program<'info, T> |
Inline Suppressions
Suppress a specific finding on a line with a trailing comment:
// sentio-ignore SW001
pub authority: ,
The comment must appear on the same line as the flagged code.
How It Works
sentio's precision comes from a two-layer analysis pipeline built on top of syn, Rust's macro-safe AST parser. Every rule operates on the actual structure of the code — typed AST nodes, not source text.
Layer 1 — Anchor Account Index
For every #[derive(Accounts)] struct, sentio extracts a typed model of each field:
AccountInfo named "authority"
type_info → kind: AccountInfo, wrappers: []
constraints → is_signer: false, owner: false, address: false,
init: false, seeds: false, bump: false, ...
This is built by anchor_accounts.rs, which uses syn's meta parser to read every key inside #[account(...)] into a strongly-typed AnchorFieldConstraints struct. Every constraint — mut, signer, has_one, seeds, bump, owner, address, init, init_if_needed, realloc, realloc::zero, close — is parsed from the AST token stream into a typed field on the struct.
Layer 2 — Instruction Analysis Index
For every function in the file, sentio builds an ordered model of three things:
Guards — if conditions, require!, assert! macros. Each guard records which semantic properties it references:
require!;
// → GuardEvidence { references_signer: true, references_key: false, order: 1 }
Calls — function and method calls, classified as Cpi, Reload, Deserialization, or Other. CPI calls also carry a cpi_account_names list — the actual account names resolved from the CpiContext struct:
let cpi_accounts = Transfer ;
transfer?;
// → CallEvidence { kind: Cpi, cpi_account_names: ["vault", "dest", "authority"], order: 3 }
Writes — assignment expressions (=, +=, -=, etc.) with the target captured as a string:
ctx.accounts.game.status = Resolved;
// → WriteEvidence { target: "ctx.accounts.game.status", order: 4 }
All three are tagged with a sequential order counter so rules can reason about what happened before and after what.
Cross-Reference Analysis (SW008)
The post-CPI reload rule is the most sophisticated. Without cross-reference tracking, any write after a CPI would produce a finding — including writing game.status = Resolved after a token transfer, which is a false positive because game wasn't part of the transfer at all.
sentio tracks variable bindings across statements to solve this:
let cpi_accounts = Transfer { from: ctx.accounts.vault, ... }→ sentio recordscpi_accounts → ["vault", "dest", "authority"]in a binding map.let cpi_ctx = CpiContext::new(prog, cpi_accounts)→ sentio resolvescpi_accountsthrough the binding map, forwarding the names tocpi_ctx.token::transfer(cpi_ctx, amount)→ sentio resolvescpi_ctx, giving the callcpi_account_names: ["vault", "dest", "authority"].- After the CPI:
game.status = Resolved→ sentio extracts account namegame, checks it against["vault", "dest", "authority"]→ not found → no finding. - After the CPI:
vault.amount -= fee→ sentio extractsvault→ found → finding.
The inline pattern (token::transfer(CpiContext::new(prog, Transfer { from: ..., to: ..., authority: ... }), amount)) is also handled — sentio traverses into the nested call expression to extract the struct fields directly.
Rule Execution
Each rule receives the AnchorAccountsIndex and the InstructionIndex for the file and combines them with boolean logic:
SW001: field.type ∈ {AccountInfo, UncheckedAccount}
&& field.name contains "authority" | "admin" | "signer" | "initializer"
&& !constraints.is_signer
&& !constraints.address
&& no guard references_signer && mentions field_name
→ flag
No heuristic scoring. No ML. Just structured data and typed predicates.
Suppression Pass
After all rule matches are collected, sentio runs a suppression pass. For each finding, it looks up the source line and checks whether it contains // sentio-ignore SWXXX. Suppressed matches are dropped before results are returned or printed.
Workspace Layout
sentio-rs/
├── crates/
│ ├── sentio-core/
│ │ ├── src/
│ │ │ ├── anchor_accounts.rs # Anchor #[account(...)] constraint parser
│ │ │ ├── instruction_analysis.rs # Guard / call / write extractor with CPI cross-reference
│ │ │ ├── rules/
│ │ │ │ └── anchor/ # One module per rule (SW001–SW020)
│ │ │ ├── scanner.rs # File walker + suppression pass
│ │ │ └── syntax.rs # syn parsing wrapper
│ │ └── tests/
│ │ ├── common/mod.rs # Shared fixture helpers
│ │ ├── fixtures/swXXX/ # risky.rs / safe.rs / suppressed.rs per rule
│ │ └── rules_swXXX.rs # Integration test per rule
│ └── sentio-cli/
│ ├── src/
│ │ ├── lib.rs # Public formatter API (render_human_report, etc.)
│ │ └── main.rs # CLI entry point (clap)
│ └── tests/
│ └── human_output.rs # Formatter integration tests
Design Philosophy
Structured analysis. sentio parses Rust source with syn — the same parser used by procedural macros — so every constraint, guard, and expression is a typed AST node. Rules ask "does this field have a seeds constraint with no bump?" against a structured model, not against source text.
Anchor-aware. sentio models Anchor's #[derive(Accounts)] structs and their full constraint vocabulary — signer, owner, address, has_one, seeds, bump, init_if_needed, realloc::zero, and more. It also understands Anchor CPI patterns including CpiContext::new and account struct resolution.
Precision over recall. A false positive wastes an auditor's time and erodes trust in the tool. Every rule ships with a real-program validation pass. When precision cannot be guaranteed, rules are flagged as manual review rather than treated as confirmed vulnerabilities.
No compiler dependency. sentio works on raw .rs source files. No rustc_private, no proc-macro expansion, no cargo build needed. Point it at any Solana program directory and it works.
Status
sentio is under active development. The rule set is growing; the AST infrastructure is stable.
Planned: SW005 (arithmetic overflow), SW006 (type cosplay), SW009/SW010 (token validation), SW013/SW014 (PDA seed issues).