Skip to content

Commit 39af277

Browse files
Merge pull request #482 from MarvyNwaokobia/feature/detect-storage-update-without-check-v2
feat(engine): detect usage of env.storage().instance().update() witho…
2 parents f677c32 + 5d064a0 commit 39af277

File tree

2 files changed

+183
-0
lines changed

2 files changed

+183
-0
lines changed

tooling/sanctifier-core/src/rules/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ pub mod unhandled_result;
2525
pub mod unsafe_prng;
2626
/// Unused local variables.
2727
pub mod unused_variable;
28+
/// Detect usage of env.storage().instance().update() without state check.
29+
pub mod storage_update_state_check;
30+
2831
/// Variable shadowing in nested scopes.
2932
pub mod variable_shadowing;
3033
use serde::Serialize;
@@ -179,6 +182,7 @@ impl RuleRegistry {
179182
registry.register(unhandled_result::UnhandledResultRule::new());
180183
registry.register(unused_variable::UnusedVariableRule::new());
181184
registry.register(shadow_storage::ShadowStorageRule::new());
185+
registry.register(storage_update_state_check::StorageUpdateStateCheckRule::new());
182186
registry.register(reentrancy::ReentrancyRule::new());
183187
registry.register(truncation_bounds::TruncationBoundsRule::new());
184188
registry.register(unsafe_prng::UnsafePrngRule::new());
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
use crate::rules::{Rule, RuleViolation, Severity};
2+
use syn::{parse_str, File, Item, Stmt, Expr};
3+
use quote::ToTokens;
4+
5+
/// Rule to detect usage of env.storage().instance().update() without a state check.
6+
pub struct StorageUpdateStateCheckRule;
7+
8+
impl StorageUpdateStateCheckRule {
9+
/// Creates a new instance of the rule.
10+
pub fn new() -> Self {
11+
Self
12+
}
13+
}
14+
15+
impl Default for StorageUpdateStateCheckRule {
16+
fn default() -> Self {
17+
Self::new()
18+
}
19+
}
20+
21+
impl Rule for StorageUpdateStateCheckRule {
22+
fn name(&self) -> &str {
23+
"storage_update_state_check"
24+
}
25+
26+
fn description(&self) -> &str {
27+
"Detects update() without preceding state check (e.g., has()) to prevent accidental state wipes."
28+
}
29+
30+
fn check(&self, source: &str) -> Vec<RuleViolation> {
31+
let file = match parse_str::<File>(source) {
32+
Ok(f) => f,
33+
Err(_) => return vec![],
34+
};
35+
36+
let mut violations = Vec::new();
37+
38+
for item in &file.items {
39+
if let Item::Impl(impl_block) = item {
40+
for impl_item in &impl_block.items {
41+
if let syn::ImplItem::Fn(fn_item) = impl_item {
42+
if let syn::Visibility::Public(_) = fn_item.vis {
43+
let fn_name = fn_item.sig.ident.to_string();
44+
if is_vulnerable_storage_update(&fn_item.block.stmts) {
45+
violations.push(RuleViolation::new(
46+
self.name(),
47+
Severity::Warning,
48+
format!(
49+
"Function '{}' calls update() without an explicit state check.",
50+
fn_name
51+
),
52+
fn_name,
53+
).with_suggestion(
54+
"Ensure there is a state check (e.g., env.storage().instance().has()) before calling update() to prevent accidental overwrites.".to_string()
55+
));
56+
}
57+
}
58+
}
59+
}
60+
}
61+
}
62+
63+
violations
64+
}
65+
66+
fn as_any(&self) -> &dyn std::any::Any {
67+
self
68+
}
69+
}
70+
71+
fn is_vulnerable_storage_update(stmts: &[Stmt]) -> bool {
72+
let mut has_check = false;
73+
74+
for stmt in stmts {
75+
if check_for_state_check(stmt) {
76+
has_check = true;
77+
}
78+
79+
if check_for_vulnerable_update(stmt, has_check) {
80+
return true;
81+
}
82+
}
83+
84+
false
85+
}
86+
87+
fn check_for_state_check(stmt: &Stmt) -> bool {
88+
match stmt {
89+
Stmt::Expr(expr, _) => is_storage_has_call(expr),
90+
Stmt::Local(local) => {
91+
if let Some(init) = &local.init {
92+
is_storage_has_call(&init.expr)
93+
} else {
94+
false
95+
}
96+
}
97+
_ => false,
98+
}
99+
}
100+
101+
fn check_for_vulnerable_update(stmt: &Stmt, has_check: bool) -> bool {
102+
match stmt {
103+
Stmt::Expr(expr, _) => {
104+
if is_storage_update_call(expr) {
105+
return !has_check;
106+
}
107+
if let Expr::If(expr_if) = expr {
108+
if is_storage_has_call(&expr_if.cond) {
109+
return false;
110+
}
111+
for s in &expr_if.then_branch.stmts {
112+
if check_for_vulnerable_update(s, has_check) {
113+
return true;
114+
}
115+
}
116+
if let Some((_, else_branch)) = &expr_if.else_branch {
117+
if let Expr::Block(expr_block) = else_branch.as_ref() {
118+
for s in &expr_block.block.stmts {
119+
if check_for_vulnerable_update(s, has_check) {
120+
return true;
121+
}
122+
}
123+
}
124+
}
125+
}
126+
}
127+
_ => {}
128+
}
129+
false
130+
}
131+
132+
fn is_storage_has_call(expr: &Expr) -> bool {
133+
let s = expr.to_token_stream().to_string().replace(" ", "");
134+
s.contains("storage().instance().has")
135+
}
136+
137+
fn is_storage_update_call(expr: &Expr) -> bool {
138+
let s = expr.to_token_stream().to_string().replace(" ", "");
139+
s.contains("storage().instance().update")
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use super::*;
145+
146+
#[test]
147+
fn test_storage_update_without_check() {
148+
let source = r#"
149+
impl MyContract {
150+
pub fn vulnerable(env: Env) {
151+
env.storage().instance().update(&KEY, |old| {
152+
Ok(new_value)
153+
});
154+
}
155+
}
156+
"#;
157+
let rule = StorageUpdateStateCheckRule::new();
158+
let violations = rule.check(source);
159+
assert!(!violations.is_empty());
160+
}
161+
162+
#[test]
163+
fn test_storage_update_with_check() {
164+
let source = r#"
165+
impl MyContract {
166+
pub fn safe(env: Env) {
167+
if env.storage().instance().has(&KEY) {
168+
env.storage().instance().update(&KEY, |old| {
169+
Ok(new_value)
170+
});
171+
}
172+
}
173+
}
174+
"#;
175+
let rule = StorageUpdateStateCheckRule::new();
176+
let violations = rule.check(source);
177+
assert!(violations.is_empty());
178+
}
179+
}

0 commit comments

Comments
 (0)