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
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ PRs that fix FPs with a regression test are preferred.
- Discuss large features in an issue first.
- No drive-by dependency or format-only noise PRs.

## Scope / limitations

Read [docs/LIMITATIONS.md](./docs/LIMITATIONS.md). Sentio is AST-only: **no ZK proof verification, no cross-program trust.**

## Questions

Use Discord or a GitHub issue. Keep security reports responsible (no exploit dumps against third parties).
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ sentio rules list

Copy [`sentio.example.toml`](./sentio.example.toml) to `sentio.toml` for excludes, fail thresholds, and per-rule overrides.

**Contribute:** [CONTRIBUTING.md](./CONTRIBUTING.md) — fork → branch → PR.
**Contribute:** [CONTRIBUTING.md](./CONTRIBUTING.md) — fork → branch → PR.
**Limits (ZK, cross-program, AST):** [docs/LIMITATIONS.md](./docs/LIMITATIONS.md).

---

Expand Down
73 changes: 69 additions & 4 deletions crates/sentio-core/src/instruction_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,24 +525,37 @@ fn extract_account_name_from_str(s: &str) -> Option<String> {
}
}

/// How an Accounts field is used in instruction bodies in this file.
/// How an Accounts field is used in instruction bodies (and seeds attrs) in this file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AccountFieldUsage {
/// Occurrences of `ctx.accounts.<field>.key()` (pubkey read only).
pub key_reads: usize,
/// Any other use (CPI, data/lamports/owner, bare pass-through, etc.).
/// Field appears in a `seeds = [...]` constraint (PDA seed input only).
pub seed_uses: usize,
/// Any other use (data/lamports/owner, CPI program, etc.).
pub other_uses: usize,
}

impl AccountFieldUsage {
/// True when the field is only read as a pubkey (typical "store admin key" pattern).
/// Requires at least one `.key()` so unused fields are not silently suppressed.
pub fn is_pubkey_only(&self) -> bool {
self.key_reads > 0 && self.other_uses == 0
self.key_reads > 0 && self.other_uses == 0 && self.seed_uses == 0
}

/// True when the account is only used as identity material: `.key()` and/or PDA
/// seed input — never as data/owner/lamports. Applies even if the account is `mut`
/// (common for payout destinations that only pass `.key()` into state or seeds).
///
/// Does **not** model ZK proofs, Groth16 public inputs, or cross-program checks —
/// those are invisible to AST analysis.
pub fn is_identity_only(&self) -> bool {
self.other_uses == 0 && (self.key_reads > 0 || self.seed_uses > 0)
}
}

/// Analyze how `field_name` is used under `*.accounts.<field>` in this file's function bodies.
/// Analyze how `field_name` is used under `*.accounts.<field>` in this file's function bodies,
/// plus references inside `seeds = [...]` constraints on Accounts structs.
///
/// Used to suppress SW001/SW002 when an AccountInfo is only a stored pubkey source
/// (e.g. `amm.admin = ctx.accounts.admin.key()`), not a data/CPI/signer authority.
Expand All @@ -552,9 +565,61 @@ pub fn analyze_account_field_usage(file: &syn::File, field_name: &str) -> Accoun
usage: AccountFieldUsage::default(),
};
visitor.visit_file(file);
visitor.usage.seed_uses += count_seed_constraint_refs(file, field_name);
visitor.usage
}

fn count_seed_constraint_refs(file: &syn::File, field_name: &str) -> usize {
let mut count = 0usize;
count_seed_refs_in_items(&file.items, field_name, &mut count);
count
}

fn count_seed_refs_in_items(items: &[syn::Item], field_name: &str, count: &mut usize) {
for item in items {
match item {
syn::Item::Struct(s) => {
for field in &s.fields {
for attr in &field.attrs {
if !attr.path().is_ident("account") {
continue;
}
let Ok(meta_list) = attr.meta.require_list() else {
continue;
};
let tokens = meta_list.tokens.to_string();
// Rough but effective: seeds = [ ..., field.key() ... ]
let compact: String =
tokens.chars().filter(|c| !c.is_whitespace()).collect();
let lower = compact.to_ascii_lowercase();
if !lower.contains("seeds=") && !lower.contains("seeds=[") {
// still may be `seeds =` with spaces already stripped → seeds=
if !compact.contains("seeds") {
continue;
}
}
if compact.contains(&format!("{field_name}.key"))
|| compact.contains(&format!("{field_name})"))
&& compact.contains("seeds")
{
// Prefer explicit .key() on the field name as seed input.
if compact.contains(&format!("{field_name}.key")) {
*count += 1;
}
}
}
}
}
syn::Item::Mod(m) => {
if let Some((_, nested)) = &m.content {
count_seed_refs_in_items(nested, field_name, count);
}
}
_ => {}
}
}
}

struct AccountFieldUsageVisitor<'a> {
field_name: &'a str,
usage: AccountFieldUsage,
Expand Down
137 changes: 130 additions & 7 deletions crates/sentio-core/src/rules/anchor/missing_owner_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ impl Rule for MissingOwnerCheckRule {
id: "SW002",
title: "Missing owner check",
severity: RuleSeverity::Critical,
description: "Detects AccountInfo or UncheckedAccount fields with no owner or address constraint and no owner guard in instruction logic, allowing an attacker to pass an account owned by any program.",
fix_guidance: "Add an owner constraint (#[account(owner = expected_program::ID)]) or an address constraint, or validate account.owner in your instruction handler.",
description: "Detects AccountInfo or UncheckedAccount fields with no owner or address constraint and no owner guard in instruction logic, allowing an attacker to pass an account owned by any program. Skips fields used only as pubkey/seed identity. Does not model ZK proofs or checks in other programs (AST limitation).",
fix_guidance: "Add an owner constraint (#[account(owner = expected_program::ID)]) or an address constraint, or validate account.owner in your instruction handler. If integrity is intentional via ZK public inputs or another program, document with /// CHECK: and use // sentio-ignore SW002 or a baseline — Sentio cannot verify that.",
};
&METADATA
}
Expand Down Expand Up @@ -69,11 +69,10 @@ impl Rule for MissingOwnerCheckRule {
continue;
}

// Stored-pubkey only: only `.key()` is read (e.g. copy admin into state).
// Owner of that account is irrelevant — any pubkey may be passed by design.
if !c.is_mut
&& analyze_account_field_usage(&file.syntax, &field_name).is_pubkey_only()
{
// Identity-only: `.key()` and/or PDA seed input — never data/owner/lamports.
// Applies even when `mut` (payout keys copied into state / seeds).
// Does NOT skip "trust me, ZK / other program validates" without usage proof.
if analyze_account_field_usage(&file.syntax, &field_name).is_identity_only() {
continue;
}

Expand Down Expand Up @@ -337,4 +336,128 @@ mod tests {
"custom .owner == must not be SW002: {findings:?}"
);
}

#[test]
fn does_not_flag_mut_pubkey_only_identity() {
let file = parse_file(
r#"
use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct Init<'info> {
#[account(init, payer = payer, space = 8 + 32)]
pub config: Account<'info, Config>,
/// CHECK: pubkey stored in config only
#[account(mut)]
pub recipient: UncheckedAccount<'info>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}

pub fn init(ctx: Context<Init>) -> Result<()> {
ctx.accounts.config.recipient = ctx.accounts.recipient.key();
Ok(())
}

#[account]
pub struct Config {
pub recipient: Pubkey,
}
"#,
);

let rule = MissingOwnerCheckRule;
let findings = rule.match_file(
&file,
&RuleContext {
files: std::slice::from_ref(&file),
},
);
assert!(
findings.is_empty(),
"mut identity-only must not be SW002: {findings:?}"
);
}

#[test]
fn does_not_flag_seed_only_account() {
let file = parse_file(
r#"
use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct CreatePda<'info> {
/// CHECK: only used as PDA seed
pub to_owner: UncheckedAccount<'info>,
#[account(
init,
payer = payer,
space = 8,
seeds = [b"pos", to_owner.key().as_ref()],
bump
)]
pub position: Account<'info, Position>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}

pub fn create(ctx: Context<CreatePda>) -> Result<()> {
Ok(())
}

#[account]
pub struct Position {}
"#,
);

let rule = MissingOwnerCheckRule;
let findings = rule.match_file(
&file,
&RuleContext {
files: std::slice::from_ref(&file),
},
);
assert!(
findings.is_empty(),
"seed-only UncheckedAccount must not be SW002: {findings:?}"
);
}

#[test]
fn still_flags_mut_data_use_without_owner() {
// ZK-bound recipient that is actually read as data still needs a visible check
// for SW002 — proof binding is out of scope for AST.
let file = parse_file(
r#"
use anchor_lang::prelude::*;

#[derive(Accounts)]
pub struct Withdraw<'info> {
/// CHECK: bound in Groth16 public inputs (not visible to AST)
#[account(mut)]
pub recipient: UncheckedAccount<'info>,
}

pub fn withdraw(ctx: Context<Withdraw>) -> Result<()> {
let _data = ctx.accounts.recipient.try_borrow_data()?;
Ok(())
}
"#,
);

let rule = MissingOwnerCheckRule;
let findings = rule.match_file(
&file,
&RuleContext {
files: std::slice::from_ref(&file),
},
);
assert_eq!(
findings.len(),
1,
"data use without owner must still flag: {findings:?}"
);
}
}
Loading
Loading