diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22aa5fd..9faec76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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). diff --git a/README.md b/README.md index 1a1f0bb..6e02cf3 100644 --- a/README.md +++ b/README.md @@ -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). --- diff --git a/crates/sentio-core/src/instruction_analysis.rs b/crates/sentio-core/src/instruction_analysis.rs index 37ab664..0eab231 100644 --- a/crates/sentio-core/src/instruction_analysis.rs +++ b/crates/sentio-core/src/instruction_analysis.rs @@ -525,12 +525,14 @@ fn extract_account_name_from_str(s: &str) -> Option { } } -/// 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..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, } @@ -538,11 +540,22 @@ 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.` in this file's function bodies. +/// Analyze how `field_name` is used under `*.accounts.` 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. @@ -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, diff --git a/crates/sentio-core/src/rules/anchor/missing_owner_check.rs b/crates/sentio-core/src/rules/anchor/missing_owner_check.rs index 981e1a1..dabc2e0 100644 --- a/crates/sentio-core/src/rules/anchor/missing_owner_check.rs +++ b/crates/sentio-core/src/rules/anchor/missing_owner_check.rs @@ -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 } @@ -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; } @@ -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) -> 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) -> 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) -> 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:?}" + ); + } } diff --git a/crates/sentio-core/src/rules/math/division_by_zero.rs b/crates/sentio-core/src/rules/math/division_by_zero.rs index 702f2ff..8ee5dd6 100644 --- a/crates/sentio-core/src/rules/math/division_by_zero.rs +++ b/crates/sentio-core/src/rules/math/division_by_zero.rs @@ -2,9 +2,10 @@ use crate::finding::SourceLocation; use crate::rules::{Rule, RuleContext, RuleMatch, RuleMetadata, RuleSeverity}; use crate::syntax::ParsedFile; use quote::ToTokens; +use std::collections::HashSet; use syn::spanned::Spanned; use syn::visit::{self, Visit}; -use syn::{BinOp, ExprBinary}; +use syn::{BinOp, Expr, ExprBinary, ImplItem, Item, Lit}; #[derive(Debug, Default)] pub struct DivisionByZeroRule; @@ -28,8 +29,10 @@ impl Rule for DivisionByZeroRule { } fn match_file(&self, file: &ParsedFile, _ctx: &RuleContext<'_>) -> Vec { + let nonzero_consts = collect_nonzero_const_names(&file.syntax); let mut collector = DivisionCollector { findings: Vec::new(), + nonzero_consts, }; visit::visit_file(&mut collector, &file.syntax); @@ -57,22 +60,23 @@ impl Rule for DivisionByZeroRule { struct DivisionCollector { findings: Vec<(String, usize, usize)>, + nonzero_consts: HashSet, } impl<'ast> Visit<'ast> for DivisionCollector { fn visit_expr_binary(&mut self, node: &'ast ExprBinary) { match &node.op { BinOp::Div(_) | BinOp::Rem(_) => { - let divisor = node.right.to_token_stream().to_string(); let op = match &node.op { BinOp::Div(_) => "/", BinOp::Rem(_) => "%", _ => unreachable!(), }; - // Only flag when the divisor is not a numeric literal — literals - // like `/ 2` or `% 100` cannot be zero at runtime. - if !is_numeric_literal(&divisor) { + // Safe: non-zero integer literal, or named const with a non-zero + // integer literal value (e.g. `% ROOT_RING_SIZE as u32`). + if !is_safe_divisor(&node.right, &self.nonzero_consts) { + let divisor = node.right.to_token_stream().to_string(); let loc = node.span().start(); self.findings.push(( format!( @@ -92,14 +96,71 @@ impl<'ast> Visit<'ast> for DivisionCollector { } } -/// Returns true when the expression is a plain numeric literal (e.g. `2`, `100u64`, `0x10`). -fn is_numeric_literal(expr: &str) -> bool { - let s = expr - .trim() - .trim_end_matches(|c: char| c.is_ascii_alphabetic()); // strip suffixes - !s.is_empty() - && s.chars() - .all(|c| c.is_ascii_digit() || c == '_' || c == 'x' || c == 'b' || c == 'o') +/// `const NAME: T = ` (file + nested mods + impl consts). +fn collect_nonzero_const_names(file: &syn::File) -> HashSet { + let mut names = HashSet::new(); + collect_nonzero_consts_from_items(&file.items, &mut names); + names +} + +fn collect_nonzero_consts_from_items(items: &[Item], names: &mut HashSet) { + for item in items { + match item { + Item::Const(item_const) if expr_is_nonzero_int_literal(&item_const.expr) => { + names.insert(item_const.ident.to_string()); + } + Item::Mod(module) => { + if let Some((_, nested)) = &module.content { + collect_nonzero_consts_from_items(nested, names); + } + } + Item::Impl(item_impl) => { + for impl_item in &item_impl.items { + if let ImplItem::Const(c) = impl_item { + if expr_is_nonzero_int_literal(&c.expr) { + names.insert(c.ident.to_string()); + } + } + } + } + _ => {} + } + } +} + +fn is_safe_divisor(expr: &Expr, nonzero_consts: &HashSet) -> bool { + match expr { + Expr::Lit(expr_lit) => lit_is_nonzero_int(&expr_lit.lit), + Expr::Paren(p) => is_safe_divisor(&p.expr, nonzero_consts), + Expr::Group(g) => is_safe_divisor(&g.expr, nonzero_consts), + Expr::Cast(c) => is_safe_divisor(&c.expr, nonzero_consts), + Expr::Reference(r) => is_safe_divisor(&r.expr, nonzero_consts), + Expr::Path(p) => { + path_last_ident(&p.path).is_some_and(|name| nonzero_consts.contains(&name)) + } + _ => false, + } +} + +fn path_last_ident(path: &syn::Path) -> Option { + path.segments.last().map(|s| s.ident.to_string()) +} + +fn expr_is_nonzero_int_literal(expr: &Expr) -> bool { + match expr { + Expr::Lit(expr_lit) => lit_is_nonzero_int(&expr_lit.lit), + Expr::Paren(p) => expr_is_nonzero_int_literal(&p.expr), + Expr::Group(g) => expr_is_nonzero_int_literal(&g.expr), + // Allow `const X: u64 = 30u64;` — already Lit with suffix via syn + _ => false, + } +} + +fn lit_is_nonzero_int(lit: &Lit) -> bool { + match lit { + Lit::Int(int_lit) => int_lit.base10_parse::().ok().is_some_and(|v| v != 0), + _ => false, + } } #[cfg(test)] @@ -117,81 +178,102 @@ mod tests { } } + fn run(source: &str) -> Vec { + let file = parse_file(source); + DivisionByZeroRule.match_file( + &file, + &RuleContext { + files: std::slice::from_ref(&file), + }, + ) + } + #[test] fn flags_division_by_variable_divisor() { - let file = parse_file( - r#" + let findings = run(r#" pub fn calc_fee(amount: u64, rate: u64) -> u64 { amount / rate } - "#, - ); - let rule = DivisionByZeroRule; - let findings = rule.match_file( - &file, - &RuleContext { - files: std::slice::from_ref(&file), - }, - ); + "#); assert_eq!(findings.len(), 1); assert_eq!(findings[0].rule_id, "SW024"); } #[test] fn flags_division_by_account_field() { - let file = parse_file( - r#" + let findings = run(r#" pub fn calc(ctx: Context, amount: u64) -> u64 { amount / ctx.accounts.config.rate } - "#, - ); - let rule = DivisionByZeroRule; - let findings = rule.match_file( - &file, - &RuleContext { - files: std::slice::from_ref(&file), - }, - ); + "#); assert_eq!(findings.len(), 1); - assert_eq!(findings[0].rule_id, "SW024"); } #[test] fn does_not_flag_literal_divisor() { - let file = parse_file( - r#" + let findings = run(r#" pub fn calc(amount: u64) -> u64 { amount / 100 } - "#, - ); - let rule = DivisionByZeroRule; - let findings = rule.match_file( - &file, - &RuleContext { - files: std::slice::from_ref(&file), - }, - ); + "#); assert!(findings.is_empty()); } #[test] fn does_not_flag_checked_div() { - let file = parse_file( - r#" + let findings = run(r#" pub fn calc(amount: u64, rate: u64) -> Option { amount.checked_div(rate) } - "#, - ); - let rule = DivisionByZeroRule; - let findings = rule.match_file( - &file, - &RuleContext { - files: std::slice::from_ref(&file), - }, + "#); + assert!(findings.is_empty()); + } + + #[test] + fn does_not_flag_nonzero_const_divisor() { + // FP from privacy/ZK codebase: ring buffer index with const size. + let findings = run(r#" + pub const ROOT_RING_SIZE: usize = 30; + + pub fn advance(head: u32) -> u32 { + (head + 1) % ROOT_RING_SIZE as u32 + } + "#); + assert!( + findings.is_empty(), + "nonzero const as divisor must be safe: {findings:?}" ); + } + + #[test] + fn does_not_flag_bare_const_name_divisor() { + let findings = run(r#" + const SCALE: u64 = 100; + pub fn pct(amount: u64) -> u64 { + amount / SCALE + } + "#); assert!(findings.is_empty()); } + + #[test] + fn flags_zero_const_divisor() { + let findings = run(r#" + const ZERO: u64 = 0; + pub fn bad(amount: u64) -> u64 { + amount / ZERO + } + "#); + assert_eq!(findings.len(), 1); + } + + #[test] + fn flags_literal_zero_divisor() { + let findings = run(r#" + pub fn bad(amount: u64) -> u64 { + amount / 0 + } + "#); + assert_eq!(findings.len(), 1); + } } diff --git a/crates/sentio-core/src/rules/rust/missing_state_change_event.rs b/crates/sentio-core/src/rules/rust/missing_state_change_event.rs index 1209302..1c608e9 100644 --- a/crates/sentio-core/src/rules/rust/missing_state_change_event.rs +++ b/crates/sentio-core/src/rules/rust/missing_state_change_event.rs @@ -13,12 +13,13 @@ impl Rule for MissingStateChangeEventRule { title: "Missing event emission on state change", severity: RuleSeverity::Low, description: "Detects instruction handlers that write to account state but never \ - call emit!() to log a structured event. Without events, off-chain \ - indexers, dashboards, and audit trails cannot observe state transitions, \ - making incidents harder to detect and investigate.", - fix_guidance: "Add emit!(MyEvent { field: value, ... }) after significant state \ - changes. Define event structs with #[event] and include the accounts \ - and values involved in the change.", + call emit!()/emit_cpi!() or msg!() to expose the change. Without \ + events or program logs, off-chain indexers and audit trails cannot \ + observe state transitions. (Sentio is AST-based: it does not model \ + ZK proofs or external indexers.)", + fix_guidance: "Add emit!(MyEvent { ... }) after significant state changes, or a \ + structured msg!(\"...\") log that indexers can parse (common outside \ + Anchor event style).", }; &METADATA } @@ -39,19 +40,25 @@ impl Rule for MissingStateChangeEventRule { continue; } - // Check if emit!() appears anywhere in the function body. + // Observability sinks: Anchor events or program logs (msg!) that many + // Solana programs / indexers use instead of emit!. let start = function.span.start_line.saturating_sub(1); let end = function.span.end_line.min(source_lines.len()); - let has_emit = source_lines[start..end] - .iter() - .any(|line| line.contains("emit!") || line.contains("emit_cpi!")); + let has_observability = source_lines[start..end].iter().any(|line| { + let t = line.trim_start(); + // Ignore commented-out sinks. + if t.starts_with("//") { + return false; + } + line.contains("emit!") || line.contains("emit_cpi!") || line.contains("msg!") + }); - if !has_emit { + if !has_observability { findings.push(RuleMatch { rule_id: "SW027", severity: RuleSeverity::Low, message: format!( - "Function `{}` writes to account state but emits no event; \ + "Function `{}` writes to account state but has no emit!() or msg!(); \ off-chain observers cannot track this state change.", function.name ), @@ -61,8 +68,8 @@ impl Rule for MissingStateChangeEventRule { column: 1, }, help: Some( - "Add emit!(MyEvent { ... }) after state changes so indexers and \ - dashboards can observe transitions." + "Add emit!(MyEvent { ... }) or a structured msg!(\"...\") after state \ + changes so indexers and dashboards can observe transitions." .to_string(), ), }); @@ -151,4 +158,30 @@ mod tests { ); assert!(findings.is_empty()); } + + #[test] + fn does_not_flag_when_msg_present() { + // FP: many programs (incl. SPL-style) use structured msg! for indexers. + let file = parse_file( + r#" + use anchor_lang::prelude::*; + pub fn init_vault(ctx: Context) -> Result<()> { + ctx.accounts.vault.bump = ctx.bumps.vault; + msg!("conf-vault-init:{}", ctx.accounts.vault.key()); + Ok(()) + } + "#, + ); + let rule = MissingStateChangeEventRule; + let findings = rule.match_file( + &file, + &RuleContext { + files: std::slice::from_ref(&file), + }, + ); + assert!( + findings.is_empty(), + "msg! should count as observability: {findings:?}" + ); + } } diff --git a/crates/sentio-core/tests/fixtures/sw024/safe.rs b/crates/sentio-core/tests/fixtures/sw024/safe.rs index 7f4b109..5bfbb59 100644 --- a/crates/sentio-core/tests/fixtures/sw024/safe.rs +++ b/crates/sentio-core/tests/fixtures/sw024/safe.rs @@ -12,6 +12,13 @@ pub fn calc_fee(ctx: Context, amount: u64) -> Result { Ok(fee) } +/// Safe: divisor is a non-zero compile-time const (incl. cast). +pub const ROOT_RING_SIZE: usize = 30; + +pub fn advance_root_ring(head: u32) -> u32 { + (head + 1) % ROOT_RING_SIZE as u32 +} + #[account] pub struct Config { pub rate: u64, diff --git a/crates/sentio-core/tests/fixtures/sw027/safe.rs b/crates/sentio-core/tests/fixtures/sw027/safe.rs index 8654d10..1d93277 100644 --- a/crates/sentio-core/tests/fixtures/sw027/safe.rs +++ b/crates/sentio-core/tests/fixtures/sw027/safe.rs @@ -13,6 +13,13 @@ pub fn update_vault(ctx: Context, new_value: u64) -> Result<()> { Ok(()) } +/// Safe: structured program log as observability (no Anchor emit!). +pub fn init_vault(ctx: Context) -> Result<()> { + ctx.accounts.vault.value = 0; + msg!("conf-vault-init:{}", ctx.accounts.vault.key()); + Ok(()) +} + #[event] pub struct VaultUpdated { pub value: u64, diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md new file mode 100644 index 0000000..7f96e56 --- /dev/null +++ b/docs/LIMITATIONS.md @@ -0,0 +1,29 @@ +# Limitations + +Sentio is an **AST-based static scanner** for Anchor/Solana Rust source. It does **not** execute programs, expand all macros, or prove cryptographic properties. + +## What Sentio can see + +- `#[account(...)]` constraints and common equivalents (`token::mint`, custom `constraint = …`) +- Instruction bodies: guards, CPI patterns, writes, basic field usage (`.key()`, data/lamports, seeds) + +## What Sentio cannot see (by design) + +| Out of scope | Why | What to do | +|--------------|-----|------------| +| **ZK / Groth16 / proof public inputs** | Proof binding is not in the Rust AST | `/// CHECK:`, `// sentio-ignore SWxxx`, or baseline | +| **Checks only in another program (CPI callee)** | Cross-program analysis not supported | Ignore / baseline; document trust in the other program | +| **Runtime-only values** | No full const-eval / symbolic execution | Prefer checked math and explicit guards | +| **Off-chain indexers / intent** | Cannot know your indexer contract | Use `emit!` or structured `msg!` if you want SW027 quiet | + +## UncheckedAccount + +Using `UncheckedAccount` is allowed. Sentio flags **missing visible guards** (owner / address / identity usage), not the type name. + +- **Safe (visible):** `constraint = x.key() == config.x`, seed-only / `.key()`-only identity, `owner =` / `address =` +- **Still flagged:** data use (e.g. `try_borrow_data`) with no owner/address guard +- **Not auto-trusted:** “integrity comes from a ZK proof” without a check we can parse + +## False positives + +Report with rule id + snippet: GitHub issues or Discord `#false-positives`. Prefer a PR with a regression fixture when fixing FPs.