Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
341 changes: 206 additions & 135 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ resolver = "2"
[workspace.package]
version = "0.3.1"
edition = "2021"
license = "MIT OR Apache-2.0"
license = "MIT"
repository = "https://github.com/sentio-security/sentio-rs"
authors = ["sentio-security"]
description = "AST-based security scanner for Solana/Anchor programs"
Expand All @@ -19,13 +19,13 @@ readme = "README.md"
[workspace.dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
dirs = "5.0"
dirs = "6.0.0"
proc-macro2 = { version = "1.0", features = ["span-locations"] }
quote = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
syn = { version = "2.0", features = ["full", "visit"] }
toml = "0.8"
ureq = { version = "2.10", features = ["json"] }
syn = { version = "3.0.3", features = ["full", "visit"] }
toml = "1.1"
ureq = { version = "3.3.0", features = ["json"] }
uuid = { version = "1.10", features = ["v4"] }
walkdir = "2.5"
51 changes: 26 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<div align="center">
<p>
<a href="https://crates.io/crates/sentio-cli"><img src="https://img.shields.io/crates/v/sentio-cli?color=C4531A&label=sentio-cli" alt="sentio-cli version" /></a>
<a href="https://crates.io/crates/sentio-cli"><img src="https://img.shields.io/crates/d/sentio-cli?color=6B4C3B&label=downloads" alt="crates.io downloads" /></a>
<a href="https://crates.io/crates/sentio-core"><img src="https://img.shields.io/crates/v/sentio-core?color=2C1810&label=sentio-core" alt="sentio-core version" /></a>
<a href="https://github.com/sentio-security/sentio-rs/blob/main/LICENSE"><img src="https://img.shields.io/crates/l/sentio-cli" alt="license" /></a>
</p>
Expand Down Expand Up @@ -224,30 +223,32 @@ By rule:

## 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 |
| SW005 | Unchecked arithmetic | High | `+`, `-`, `*`, `+=`, `-=`, `*=` on account fields with no checked math; can overflow in release builds |
| SW006 | Type cosplay | Critical | `try_from_slice` without a discriminator check; a malicious account type can be deserialized as another |
| SW008 | Missing post-CPI reload | High | Account written after a CPI that may have mutated it, without an intervening `reload()` |
| SW009 | Missing token mint check | High | Mutable `TokenAccount` with no `token::mint` constraint and no `associated_token`, allowing wrong-mint deposits |
| SW010 | Missing token owner check | High | Mutable `TokenAccount` with no `token::authority` or authority `has_one`, allowing unauthorized withdrawals |
| 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 |
| SW013 | PDA seed unvalidated account | High | PDA seeds reference an `AccountInfo`/`UncheckedAccount` sibling with no `owner`, `address`, or `signer` constraint |
| SW014 | PDA bump not canonical | Medium | `bump = <bare_identifier>` uses a caller-supplied bump instead of Anchor's canonical derivation |
| 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>` |
| SW021 | PDA seed collision risk | High | Adjacent variable-length seeds (e.g. `name.as_bytes()` next to `symbol.as_bytes()`) with no fixed-length seed between them, allowing different inputs to derive the same PDA |
| SW022 | Missing close constraint | High | Manual lamport draining to close accounts without `#[account(close = ...)]`; account data not zeroed, leaving it open to reinitialization with stale data |
| SW023 | Unvalidated remaining_accounts in CPI | High | `ctx.remaining_accounts` forwarded into a CPI; unconstrained accounts retain outer-transaction signer privileges inside the call, enabling privilege escalation |
| SW024 | Division by zero | High | Division or modulo where the divisor is a variable or account field with no prior zero-check; a zero divisor panics and fails the transaction |
| SW025 | unwrap() / expect() in handler | Medium | `.unwrap()` or `.expect()` in instruction code panics on None/Err, failing the transaction with a generic error and exposing a DoS vector on user-controlled inputs |
| SW026 | create_program_address usage | High | `create_program_address` accepts a caller-supplied bump and does not enforce canonical derivation; use `find_program_address` or Anchor's `seeds + bump` constraint instead |
| SW027 | Missing event on state change | Low | Instruction handler writes to account state but emits no `emit!()` event, leaving off-chain indexers and audit trails blind to the state transition |
Severities follow an audit rubric: **Critical** = direct value loss / compromise with minimal preconditions; **High** = value loss or corruption with one clear precondition; **Medium** = needs chaining; **Low** = hygiene.

| ID | Title | Severity |
| --- | --- | --- |
| SW001 | Missing signer check | Critical |
| SW002 | Missing owner check | Critical |
| SW003 | Arbitrary CPI target | Critical |
| SW005 | Unchecked arithmetic | High |
| SW006 | Type cosplay — missing discriminator check | Critical |
| SW008 | Missing post-CPI account reload | High |
| SW009 | Missing token account mint check | High |
| SW010 | Missing token account owner check | Critical |
| SW011 | AccountInfo used as data account | High |
| SW012 | Missing seeds + bump on PDA | High |
| SW013 | PDA seed references unvalidated account | High |
| SW014 | PDA bump may not be canonical | High |
| SW016 | init_if_needed usage (manual review) | High |
| SW018 | Missing realloc::zero = true | Low |
| SW020 | AccountInfo used as CPI target program | Critical |
| SW021 | PDA seed collision risk | High |
| SW022 | Manual account closure without close constraint | High |
| SW023 | Unvalidated remaining_accounts forwarded to CPI | Critical |
| SW024 | Division by zero | High |
| SW025 | unwrap() / expect() in instruction handler | Medium |
| SW026 | create_program_address used instead of find_program_address | High |
| SW027 | Missing event emission on state change | Low |

### Inline Suppressions

Expand Down
13 changes: 10 additions & 3 deletions crates/sentio-cli/src/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
//! install into a single "unique machine" count rather than inflating on
//! every run. Set `SENTIO_NO_TELEMETRY=1` to disable the ping entirely.

use std::time::Duration;

const NO_TELEMETRY_ENV: &str = "SENTIO_NO_TELEMETRY";

/// Endpoint that receives version-check pings.
Expand All @@ -30,16 +32,21 @@ pub fn check_version(installed: &str) -> VersionCheck {
return VersionCheck { latest: None };
};

let mut request = ureq::get(endpoint).query("version", installed);
// ureq 3.x: timeouts live on config (Agent or per-request), not RequestBuilder.
let mut request = ureq::get(endpoint)
.config()
.timeout_global(Some(Duration::from_secs(2)))
.build()
.query("version", installed);

if let Some(id) = telemetry_id() {
request = request.query("id", &id);
}

let latest = request
.timeout(std::time::Duration::from_secs(2))
.call()
.ok()
.and_then(|response| response.into_json::<serde_json::Value>().ok())
.and_then(|mut response| response.body_mut().read_json::<serde_json::Value>().ok())
.and_then(|body| {
body.get("latest")
.and_then(|v| v.as_str())
Expand Down
41 changes: 33 additions & 8 deletions crates/sentio-core/src/instruction_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,15 +215,37 @@ impl FunctionBodyCollector {
extract_account_name_from_str(&val)
})
.collect(),
syn::Expr::Array(a) => a
.elems
.iter()
.flat_map(|e| self.extract_account_names_from_expr(e))
.collect(),
syn::Expr::Repeat(r) => self.extract_account_names_from_expr(&r.expr),
syn::Expr::Call(call) => {
let func = normalize_tokens(&call.func.to_token_stream().to_string());
if func.contains("CpiContext::new") {
if let Some(accounts_arg) = call.args.iter().nth(1) {
return self.extract_account_names_from_expr(accounts_arg);
}
// Collect program + accounts args (both matter for CPI analysis).
return call
.args
.iter()
.flat_map(|arg| self.extract_account_names_from_expr(arg))
.collect();
}
// `foo.to_account_info()` as a bare call is rare; method form handled below.
vec![]
}
syn::Expr::MethodCall(m) => {
let method = m.method.to_string();
if method == "to_account_info" || method == "clone" || method == "into" {
let recv = normalize_tokens(&m.receiver.to_token_stream().to_string());
if let Some(name) = extract_account_name_from_str(&recv) {
return vec![name];
}
// Nested: accounts.buyer.to_account_info()
return self.extract_account_names_from_expr(&m.receiver);
}
self.extract_account_names_from_expr(&m.receiver)
}
syn::Expr::Path(p) => {
let var = p
.path
Expand All @@ -234,6 +256,8 @@ impl FunctionBodyCollector {
self.let_bindings.get(&var).cloned().unwrap_or_default()
}
syn::Expr::Reference(r) => self.extract_account_names_from_expr(&r.expr),
syn::Expr::Paren(p) => self.extract_account_names_from_expr(&p.expr),
syn::Expr::Try(t) => self.extract_account_names_from_expr(&t.expr),
_ => vec![],
}
}
Expand Down Expand Up @@ -294,12 +318,13 @@ impl<'ast> Visit<'ast> for FunctionBodyCollector {
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
let callee = normalize_tokens(&node.func.to_token_stream().to_string());
let cpi_account_names = if classify_call_kind(&callee) == CallKind::Cpi {
let mut found = vec![];
// Merge names from all args (invoke metas array + CpiContext builders).
let mut found = Vec::new();
for arg in &node.args {
let names = self.extract_account_names_from_expr(arg);
if !names.is_empty() {
found = names;
break;
for name in self.extract_account_names_from_expr(arg) {
if !found.iter().any(|n| n == &name) {
found.push(name);
}
}
}
found
Expand Down
Loading
Loading