Skip to main content

forge_lint/sol/low/
missing_events_access_control.rs

1use super::MissingEventsAccessControl;
2use crate::{
3    linter::{LateLintPass, LintContext},
4    sol::{
5        Severity, SolLint,
6        analysis::{
7            branch_always_exits, for_each_lhs_var, guard_vars, is_protected, is_sender_member,
8            is_zero_value, lhs_local_var, referenced_item, underlying_var,
9        },
10    },
11};
12use solar::{
13    ast::{ContractKind, DataLocation, StateMutability, Visibility},
14    interface::{Span, data_structures::Never},
15    sema::{
16        Gcx,
17        hir::{
18            self, EventId, Expr, ExprKind, FunctionId, ItemId, Stmt, StmtKind, VariableId, Visit,
19        },
20    },
21};
22use std::{
23    collections::{HashMap, HashSet},
24    iter,
25    ops::ControlFlow,
26};
27
28declare_forge_lint!(
29    MISSING_EVENTS_ACCESS_CONTROL,
30    Severity::Low,
31    "missing-events-access-control",
32    "access control changes without an event"
33);
34
35impl<'gcx> LateLintPass<'gcx> for MissingEventsAccessControl {
36    fn check_contract(
37        &mut self,
38        ctx: &LintContext,
39        gcx: Gcx<'gcx>,
40        contract: &'gcx hir::Contract<'gcx>,
41    ) {
42        if !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) {
43            return;
44        }
45
46        // Every state variable some access check in the contract depends on.
47        let functions: Vec<_> = contract.all_functions().collect();
48        let targets: HashSet<_> = functions.iter().flat_map(|&id| guard_vars(gcx, id)).collect();
49        if targets.is_empty() {
50            return;
51        }
52
53        for func_id in functions {
54            let func = gcx.hir.function(func_id);
55            let is_entry_point = func.kind.is_function()
56                && matches!(func.visibility, Visibility::Public | Visibility::External)
57                && !func.is_constructor()
58                && !func.is_special()
59                && !matches!(func.state_mutability, StateMutability::Pure | StateMutability::View);
60            if !is_entry_point || !is_protected(gcx, func_id) {
61                continue;
62            }
63
64            let guard_targets = guard_vars(gcx, func_id);
65            let mut analyzer = WriteAnalyzer {
66                gcx,
67                targets: &targets,
68                guard_targets: &guard_targets,
69                state: State {
70                    taint: func
71                        .parameters
72                        .iter()
73                        .map(|&p| (p, Sources::from([Source::Var(p)])))
74                        .collect(),
75                    ..Default::default()
76                },
77                call_stack: Vec::new(),
78            };
79            analyzer.analyze_function(func_id);
80
81            let mut emitted = HashSet::new();
82            for write in analyzer.state.writes {
83                if write.evented || !emitted.insert(write.var_id) {
84                    continue;
85                }
86                let name = gcx
87                    .hir
88                    .variable(write.var_id)
89                    .name
90                    .map_or_else(|| "state variable".to_string(), |name| name.to_string());
91                ctx.emit_with_msg(
92                    &MISSING_EVENTS_ACCESS_CONTROL,
93                    write.span,
94                    format!("`{name}` is changed without an event but is used for access control"),
95                );
96            }
97        }
98    }
99}
100
101/// Calls `f` on every index and slice bound along the spine of an lvalue.
102fn for_each_lhs_index<'gcx>(expr: &'gcx Expr<'gcx>, f: &mut impl FnMut(&'gcx Expr<'gcx>)) {
103    match &expr.peel_parens().kind {
104        ExprKind::Index(base, index) => {
105            for_each_lhs_index(base, f);
106            if let Some(index) = index {
107                f(index);
108            }
109        }
110        ExprKind::Slice(base, start, end) => {
111            for_each_lhs_index(base, f);
112            for bound in start.iter().chain(end) {
113                f(bound);
114            }
115        }
116        ExprKind::Member(base, _) | ExprKind::Payable(base) | ExprKind::Unary(_, base) => {
117            for_each_lhs_index(base, f)
118        }
119        ExprKind::Tuple(exprs) => exprs.iter().flatten().for_each(|e| for_each_lhs_index(e, f)),
120        _ => {}
121    }
122}
123
124// --- Writes without events --------------------------------------------------------------------
125
126/// Where a written value may come from: an entry-point parameter or state variable, or the caller.
127#[derive(Clone, Copy, PartialEq, Eq, Hash)]
128enum Source {
129    Var(VariableId),
130    Sender,
131}
132
133type Sources = HashSet<Source>;
134
135#[derive(Clone)]
136struct StateWrite {
137    var_id: VariableId,
138    span: Span,
139    sources: Sources,
140    /// The written value is a literal zero/false, so no source is needed for an event to match.
141    fixed_clear: bool,
142    evented: bool,
143}
144
145#[derive(Clone, Default)]
146struct State {
147    /// Sources each local may currently hold.
148    taint: HashMap<VariableId, Sources>,
149    /// Storage-pointer locals and the state variable they alias.
150    storage_aliases: HashMap<VariableId, VariableId>,
151    writes: Vec<StateWrite>,
152}
153
154/// Collects writes to `targets` reachable from an entry point and marks those an `emit` covers.
155struct WriteAnalyzer<'a, 'gcx> {
156    gcx: Gcx<'gcx>,
157    targets: &'a HashSet<VariableId>,
158    /// Targets checked by this entry point's own guards; clearing one of them is reportable even
159    /// when the written value carries no source.
160    guard_targets: &'a HashSet<VariableId>,
161    state: State,
162    call_stack: Vec<FunctionId>,
163}
164
165impl<'gcx> WriteAnalyzer<'_, 'gcx> {
166    fn analyze_function(&mut self, func_id: FunctionId) {
167        if self.call_stack.contains(&func_id) {
168            return;
169        }
170        let func = self.gcx.hir.function(func_id);
171        let Some(body) = func.body else { return };
172        self.call_stack.push(func_id);
173        for modifier in func.modifiers {
174            if let Some(modifier_id) = modifier.id.as_function() {
175                let _ = self.visit_call_args(&modifier.args);
176                self.analyze_call(modifier_id, |index| modifier.args.exprs().nth(index));
177            }
178        }
179        for stmt in body.stmts {
180            let _ = self.visit_stmt(stmt);
181        }
182        self.call_stack.pop();
183    }
184
185    /// Inlines `callee_id` with its parameters bound to argument sources; locals and storage
186    /// aliases are callee-private, pending writes flow back to the caller.
187    fn analyze_call(
188        &mut self,
189        callee_id: FunctionId,
190        mut argument: impl FnMut(usize) -> Option<&'gcx Expr<'gcx>>,
191    ) {
192        let params = self
193            .gcx
194            .hir
195            .function(callee_id)
196            .parameters
197            .iter()
198            .enumerate()
199            .filter_map(|(index, &param)| {
200                let sources = self.value_sources(argument(index)?);
201                (!sources.is_empty()).then_some((param, sources))
202            })
203            .collect();
204        let saved_taint = std::mem::replace(&mut self.state.taint, params);
205        let saved_aliases = std::mem::take(&mut self.state.storage_aliases);
206        self.analyze_function(callee_id);
207        self.state.taint = saved_taint;
208        self.state.storage_aliases = saved_aliases;
209    }
210
211    /// Sources flowing into `expr`: `msg.sender`, state variables and tainted locals.
212    fn value_sources(&self, expr: &Expr<'_>) -> Sources {
213        let mut out = Sources::new();
214        let _ = expr.visit(&mut |e| {
215            if is_sender_member(self.gcx, e) {
216                out.insert(Source::Sender);
217            }
218            if let Some(var_id) = underlying_var(self.gcx, e) {
219                if self.gcx.hir.variable(var_id).kind.is_state() {
220                    out.insert(Source::Var(var_id));
221                }
222                if let Some(sources) = self.state.taint.get(&var_id) {
223                    out.extend(sources);
224                }
225            }
226            ControlFlow::<()>::Continue(())
227        });
228        out
229    }
230
231    /// State variables written through `lhs`, resolving storage pointers to their roots.
232    fn lhs_state_vars(&self, lhs: &Expr<'_>) -> Vec<VariableId> {
233        let mut vars = Vec::new();
234        for_each_lhs_var(self.gcx, lhs, &mut |var_id| {
235            let root = if self.gcx.hir.variable(var_id).kind.is_state() {
236                Some(var_id)
237            } else {
238                self.state.storage_aliases.get(&var_id).copied()
239            };
240            if let Some(root) = root
241                && !vars.contains(&root)
242            {
243                vars.push(root);
244            }
245        });
246        vars
247    }
248
249    fn record_writes(&mut self, lhs: &Expr<'_>, sources: &Sources, fixed_clear: bool) {
250        for var_id in self.lhs_state_vars(lhs) {
251            if self.targets.contains(&var_id)
252                && (!sources.is_empty() || (fixed_clear && self.guard_targets.contains(&var_id)))
253            {
254                self.state.writes.push(StateWrite {
255                    var_id,
256                    span: lhs.span,
257                    sources: sources.clone(),
258                    fixed_clear,
259                    evented: false,
260                });
261            }
262        }
263    }
264
265    fn set_taint(&mut self, var_id: VariableId, sources: Sources) {
266        if sources.is_empty() {
267            self.state.taint.remove(&var_id);
268        } else {
269            self.state.taint.insert(var_id, sources);
270        }
271    }
272
273    /// Records `var_id = value`, tracking which state variable a storage pointer aliases.
274    fn set_local(&mut self, var_id: VariableId, sources: Sources, value: &Expr<'_>) {
275        self.set_taint(var_id, sources);
276        let root = (self.gcx.hir.variable(var_id).data_location == Some(DataLocation::Storage))
277            .then(|| self.lhs_state_vars(value).into_iter().next())
278            .flatten();
279        match root {
280            Some(root) => self.state.storage_aliases.insert(var_id, root),
281            None => self.state.storage_aliases.remove(&var_id),
282        };
283    }
284
285    /// Marks pending writes covered by `emit`: the event must mention the variable and share a
286    /// source with the write (or the write must be a fixed clear).
287    fn mark_event(&mut self, expr: &Expr<'_>) {
288        let Some(event_id) = emitted_event_id(self.gcx, expr) else { return };
289        let event_sources = self.value_sources(expr);
290        for write in &mut self.state.writes {
291            if !write.evented
292                && (write.fixed_clear || !write.sources.is_disjoint(&event_sources))
293                && event_mentions_state_var(self.gcx, event_id, write.var_id)
294            {
295                write.evented = true;
296            }
297        }
298    }
299}
300
301impl<'gcx> Visit<'gcx> for WriteAnalyzer<'_, 'gcx> {
302    type BreakValue = Never;
303
304    fn hir(&self) -> &'gcx hir::Hir<'gcx> {
305        &self.gcx.hir
306    }
307
308    fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow<Never> {
309        match stmt.kind {
310            StmtKind::DeclSingle(var_id) => {
311                if let Some(init) = self.gcx.hir.variable(var_id).initializer {
312                    self.visit_expr(init)?;
313                    let sources = self.value_sources(init);
314                    self.set_local(var_id, sources, init);
315                }
316            }
317            StmtKind::DeclMulti(vars, expr) => {
318                self.visit_expr(expr)?;
319                let sources = self.value_sources(expr);
320                for var_id in vars.iter().flatten() {
321                    self.set_taint(*var_id, sources.clone());
322                }
323            }
324            StmtKind::If(cond, then_stmt, else_stmt) => {
325                self.visit_expr(cond)?;
326                let base = self.state.clone();
327                self.visit_stmt(then_stmt)?;
328                let then_state = std::mem::replace(&mut self.state, base.clone());
329                if let Some(else_stmt) = else_stmt {
330                    self.visit_stmt(else_stmt)?;
331                }
332                let else_state = std::mem::take(&mut self.state);
333                self.state = merge_branches(
334                    base,
335                    then_state,
336                    else_state,
337                    branch_always_exits(self.gcx, then_stmt),
338                    else_stmt.is_some_and(|expr| branch_always_exits(self.gcx, expr)),
339                );
340            }
341            StmtKind::Emit(expr) => {
342                self.visit_expr(expr)?;
343                self.mark_event(expr);
344            }
345            _ => return self.walk_stmt(stmt),
346        }
347        ControlFlow::Continue(())
348    }
349
350    fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<Never> {
351        match &expr.kind {
352            ExprKind::Assign(lhs, op, rhs) => {
353                self.visit_expr(rhs)?;
354                self.visit_expr(lhs)?;
355                let mut sources = self.value_sources(rhs);
356                for_each_lhs_index(lhs, &mut |index| sources.extend(self.value_sources(index)));
357                if op.is_some() {
358                    sources.extend(self.value_sources(lhs));
359                }
360                self.record_writes(lhs, &sources, is_zero_value(rhs));
361                if let Some(local) = lhs_local_var(self.gcx, lhs) {
362                    self.set_local(local, sources, rhs);
363                }
364                ControlFlow::Continue(())
365            }
366            ExprKind::Delete(inner) => {
367                let mut sources = Sources::new();
368                for_each_lhs_index(inner, &mut |index| sources.extend(self.value_sources(index)));
369                self.record_writes(inner, &sources, true);
370                self.walk_expr(expr)
371            }
372            ExprKind::Call(callee, ..) => {
373                self.walk_expr(expr)?;
374                if matches!(callee.peel_parens().kind, ExprKind::Ident(_))
375                    && let Some(callee_id) = self.gcx.resolved_function(callee)
376                {
377                    let gcx = self.gcx;
378                    self.analyze_call(callee_id, |index| gcx.call_arg(expr, index));
379                }
380                ControlFlow::Continue(())
381            }
382            _ => self.walk_expr(expr),
383        }
384    }
385}
386
387/// Joins the two arms of an `if`: a pending write stays covered only if both arms emitted for it,
388/// while taint and aliases come from whichever arms can continue past the `if`.
389fn merge_branches(
390    base: State,
391    then_state: State,
392    else_state: State,
393    then_exits: bool,
394    else_exits: bool,
395) -> State {
396    let mut writes = base.writes;
397    for (i, write) in writes.iter_mut().enumerate() {
398        write.evented = then_state.writes[i].evented && else_state.writes[i].evented;
399    }
400    let n = writes.len();
401    writes.extend_from_slice(&then_state.writes[n..]);
402    writes.extend_from_slice(&else_state.writes[n..]);
403
404    let (taint, storage_aliases) = match (then_exits, else_exits) {
405        (true, true) => (base.taint, base.storage_aliases),
406        (true, false) => (else_state.taint, else_state.storage_aliases),
407        (false, true) => (then_state.taint, then_state.storage_aliases),
408        (false, false) => {
409            let mut taint = then_state.taint;
410            for (var_id, sources) in else_state.taint {
411                taint.entry(var_id).or_default().extend(sources);
412            }
413            let storage_aliases = then_state
414                .storage_aliases
415                .into_iter()
416                .filter(|(alias, root)| else_state.storage_aliases.get(alias) == Some(root))
417                .collect();
418            (taint, storage_aliases)
419        }
420    };
421    State { taint, storage_aliases, writes }
422}
423
424fn emitted_event_id(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<EventId> {
425    let ExprKind::Call(callee, ..) = &expr.peel_parens().kind else { return None };
426    match referenced_item(gcx, callee)? {
427        ItemId::Event(event_id) => Some(event_id),
428        _ => None,
429    }
430}
431
432/// Whether the event name or one of its parameter names mentions the state variable: its
433/// normalized name, its singular form, or a role keyword it contains.
434fn event_mentions_state_var(gcx: Gcx<'_>, event_id: EventId, var_id: VariableId) -> bool {
435    let Some(var_name) = gcx.hir.variable(var_id).name else { return false };
436    let var_name = normalize(var_name.as_str());
437    let mut keywords = vec![var_name.as_str()];
438    keywords.extend(var_name.strip_suffix('s').filter(|singular| !singular.is_empty()));
439    let roles = ["owner", "admin", "guardian", "manager", "role"];
440    keywords.extend(roles.into_iter().filter(|role| var_name.contains(role)));
441
442    let event = gcx.hir.event(event_id);
443    let param_names = event.parameters.iter().filter_map(|&p| gcx.hir.variable(p).name);
444    iter::once(event.name).chain(param_names).any(|name| {
445        let name = normalize(name.as_str());
446        keywords.iter().any(|keyword| !keyword.is_empty() && name.contains(keyword))
447    })
448}
449
450fn normalize(name: &str) -> String {
451    name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
452}