Skip to main content

sentio_core/
rules.rs

1pub mod anchor;
2
3use crate::finding::{Finding, Severity, SourceLocation};
4use crate::syntax::ParsedFile;
5use serde::Serialize;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "lowercase")]
10pub enum RuleSeverity {
11    Low,
12    Medium,
13    High,
14    Critical,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct RuleMetadata {
19    pub id: &'static str,
20    pub title: &'static str,
21    pub severity: RuleSeverity,
22    pub description: &'static str,
23    pub fix_guidance: &'static str,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RuleMatch {
28    pub rule_id: &'static str,
29    pub severity: RuleSeverity,
30    pub message: String,
31    pub location: SourceLocation,
32    pub help: Option<String>,
33}
34
35pub trait Rule {
36    fn metadata(&self) -> &RuleMetadata;
37    fn match_file(&self, file: &ParsedFile, ctx: &RuleContext<'_>) -> Vec<RuleMatch>;
38}
39
40#[derive(Clone, Copy)]
41pub struct RuleContext<'a> {
42    pub files: &'a [ParsedFile],
43}
44
45pub struct RuleRegistry {
46    rules: Vec<Box<dyn Rule>>,
47}
48
49impl RuleRegistry {
50    pub fn new(rules: Vec<Box<dyn Rule>>) -> Self {
51        Self { rules }
52    }
53
54    pub fn baseline() -> Self {
55        Self::new(vec![
56            Box::new(anchor::missing_signer_check::MissingSignerCheckRule::default()),
57            Box::new(anchor::missing_pda_seeds_bump::MissingPdaSeedsBumpRule::default()),
58            Box::new(anchor::init_if_needed_usage::InitIfNeededUsageRule::default()),
59            Box::new(anchor::missing_realloc_zero::MissingReallocZeroRule::default()),
60            Box::new(anchor::account_info_as_data_account::AccountInfoAsDataAccountRule::default()),
61            Box::new(anchor::account_info_as_cpi_program::AccountInfoAsCpiProgramRule::default()),
62            Box::new(anchor::missing_owner_check::MissingOwnerCheckRule::default()),
63            Box::new(anchor::arbitrary_cpi::ArbitraryCpiRule::default()),
64            Box::new(anchor::missing_cpi_reload::MissingCpiReloadRule::default()),
65            Box::new(anchor::unchecked_arithmetic::UncheckedArithmeticRule::default()),
66            Box::new(anchor::type_cosplay::TypeCosplayRule::default()),
67            Box::new(anchor::missing_token_mint_check::MissingTokenMintCheckRule::default()),
68            Box::new(anchor::missing_token_owner_check::MissingTokenOwnerCheckRule::default()),
69            Box::new(anchor::pda_seed_unvalidated_account::PdaSeedUnvalidatedAccountRule::default()),
70            Box::new(anchor::pda_bump_not_canonical::PdaBumpNotCanonicalRule::default()),
71        ])
72    }
73
74    pub fn all(&self) -> &[Box<dyn Rule>] {
75        &self.rules
76    }
77
78    pub fn matching_rules(&self, rule_filter: Option<&str>) -> Vec<&dyn Rule> {
79        let filter = rule_filter
80            .map(normalize_rule_id)
81            .filter(|filter| !filter.is_empty());
82
83        self.rules
84            .iter()
85            .map(|rule| rule.as_ref())
86            .filter(|rule| {
87                filter
88                    .as_ref()
89                    .map_or(true, |filter| rule.metadata().id.eq_ignore_ascii_case(filter))
90            })
91            .collect()
92    }
93}
94
95impl Default for RuleRegistry {
96    fn default() -> Self {
97        Self::baseline()
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct SuppressionSet {
103    same_line: HashMap<usize, Vec<String>>,
104    next_line: HashMap<usize, Vec<String>>,
105}
106
107impl SuppressionSet {
108    pub fn empty() -> Self {
109        Self {
110            same_line: HashMap::new(),
111            next_line: HashMap::new(),
112        }
113    }
114
115    pub fn from_source(source: &str) -> Self {
116        let mut same_line: HashMap<usize, Vec<String>> = HashMap::new();
117        let mut next_line: HashMap<usize, Vec<String>> = HashMap::new();
118
119        for (idx, line) in source.lines().enumerate() {
120            let line_no = idx + 1;
121            if let Some(ids) = parse_ignore_directive(line, "sentio-ignore") {
122                same_line.insert(line_no, ids);
123            }
124            if let Some(ids) = parse_ignore_directive(line, "sentio-ignore-next-line") {
125                next_line.insert(line_no + 1, ids);
126            }
127        }
128
129        Self {
130            same_line,
131            next_line,
132        }
133    }
134
135    pub fn is_suppressed(&self, finding: &Finding) -> bool {
136        let rule_id = finding.rule_id.to_uppercase();
137        let line = finding.location.line;
138
139        self.same_line
140            .get(&line)
141            .is_some_and(|ids| ids.iter().any(|id| id == &rule_id))
142            || self
143                .next_line
144                .get(&line)
145                .is_some_and(|ids| ids.iter().any(|id| id == &rule_id))
146    }
147}
148
149pub fn convert_severity(severity: RuleSeverity) -> Severity {
150    match severity {
151        RuleSeverity::Low => Severity::Low,
152        RuleSeverity::Medium => Severity::Medium,
153        RuleSeverity::High => Severity::High,
154        RuleSeverity::Critical => Severity::Critical,
155    }
156}
157
158fn normalize_rule_id(rule_id: &str) -> String {
159    rule_id.trim().to_uppercase()
160}
161
162fn parse_ignore_directive(line: &str, directive: &str) -> Option<Vec<String>> {
163    let lower = line.to_lowercase();
164    let compact = lower.replace(char::is_whitespace, "");
165    let marker = format!("//{directive}");
166    let start = compact.find(&marker)? + marker.len();
167    let ids = compact[start..]
168        .split(|c: char| c == ',' || c.is_whitespace())
169        .map(|s| s.trim().to_uppercase())
170        .filter(|id| is_rule_id(id))
171        .collect::<Vec<_>>();
172
173    if ids.is_empty() {
174        None
175    } else {
176        Some(ids)
177    }
178}
179
180fn is_rule_id(id: &str) -> bool {
181    id.len() == 5
182        && id.starts_with("SW")
183        && id[2..].chars().all(|c| c.is_ascii_digit())
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn parses_same_line_and_next_line_suppressions() {
192        let suppressions = SuppressionSet::from_source(
193            r#"
194            // sentio-ignore SW012, SW018
195            let a = 1;
196            // sentio-ignore-next-line SW012
197            let b = 2;
198            "#,
199        );
200
201        let same_line = Finding {
202            rule_id: "SW012".to_string(),
203            severity: Severity::High,
204            message: String::new(),
205            location: SourceLocation {
206                path: "x.rs".to_string(),
207                line: 2,
208                column: 1,
209            },
210            help: None,
211            suppressed: false,
212        };
213        let next_line = Finding {
214            rule_id: "SW012".to_string(),
215            severity: Severity::High,
216            message: String::new(),
217            location: SourceLocation {
218                path: "x.rs".to_string(),
219                line: 5,
220                column: 1,
221            },
222            help: None,
223            suppressed: false,
224        };
225
226        assert!(suppressions.is_suppressed(&same_line));
227        assert!(suppressions.is_suppressed(&next_line));
228    }
229}