Skip to main content

weggli_ruleset/
rule.rs

1use std::borrow::Borrow;
2use std::collections::{HashMap, HashSet};
3use std::fmt::Display;
4use std::fs::File;
5use std::io::BufReader;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use memchr::memmem;
10use nonempty::NonEmpty;
11use regex::Regex;
12use rustc_hash::FxHashSet;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15use tree_sitter::Tree;
16use walkdir::WalkDir;
17use weggli::query::QueryTree;
18use weggli::result::QueryResult;
19use weggli::RegexMap;
20
21#[derive(Debug, Error)]
22pub enum RuleError {
23    #[error(transparent)]
24    Check(#[from] CheckError),
25    #[error("cannot parse rule: {0}")]
26    Parse(#[from] serde_yaml::Error),
27    #[error("cannot parse rule file {}: {1}", _0.display())]
28    ParseFile(PathBuf, anyhow::Error),
29    #[error("rule has no checks")]
30    NoChecks,
31    #[error("rule has no identifier")]
32    NoId,
33    #[error("rule has multiple checks with the same name")]
34    MultipleChecksWithSameName,
35    #[error(transparent)]
36    Regex(#[from] RegexError),
37}
38
39#[derive(Debug, Error)]
40pub enum CheckError {
41    #[error("check has no name")]
42    NoCheckName,
43    #[error("check has no patterns")]
44    NoCheckPatterns,
45    #[error("regex constraint has an invalid query variable `{0}`")]
46    InvalidQueryVariable(String),
47    #[error("invalid pattern: {0}")]
48    Pattern(#[from] weggli::WeggliError),
49    #[error(transparent)]
50    Regex(#[from] RegexError),
51}
52
53#[derive(Debug, Error)]
54pub enum RegexError {
55    #[error("`{0}` is not in the format `var=regex`")]
56    InvalidFormat(String),
57    #[error("invalid regex: {0}")]
58    InvalidRegex(#[from] regex::Error),
59}
60
61#[derive(Clone)]
62pub struct RuleSet {
63    rules: Arc<[(String, Arc<Rule>)]>,
64}
65
66impl RuleSet {
67    pub fn from_directory(root: impl AsRef<Path>, ignore_errors: bool) -> Result<Self, RuleError> {
68        let walker = WalkDir::new(root);
69        let mut rules = Vec::new();
70
71        for dirent in walker
72            .into_iter()
73            .filter_entry(|e| {
74                e.file_type().is_dir() || {
75                    matches!(e.path().extension(), Some(x) if
76                    ["yml", "yaml"].contains(&x.to_string_lossy().as_ref()))
77                }
78            })
79            .filter_map(Result::ok)
80        {
81            if dirent.file_type().is_dir() {
82                continue;
83            }
84
85            let path = dirent.path();
86            match Rule::from_file(path) {
87                Ok(rule) => {
88                    rules.push((path.display().to_string(), Arc::new(rule)));
89                }
90                Err(e) => {
91                    if !ignore_errors {
92                        return Err(e);
93                    }
94                }
95            }
96        }
97
98        Ok(Self {
99            rules: Arc::from(rules),
100        })
101    }
102
103    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, RuleError> {
104        let path = path.as_ref();
105        Ok(Self {
106            rules: Arc::from(vec![(
107                path.display().to_string(),
108                Arc::new(Rule::from_file(path)?),
109            )]),
110        })
111    }
112
113    pub fn from_str(rule: impl AsRef<str>) -> Result<Self, RuleError> {
114        Ok(Self {
115            rules: Arc::from(vec![(
116                String::from("default"),
117                Arc::new(Rule::from_str(rule)?),
118            )]),
119        })
120    }
121
122    pub fn get(&self, index: usize) -> Option<Arc<Rule>> {
123        self.rules.get(index).map(|(_, r)| r.clone())
124    }
125
126    pub fn get_ref(&self, index: usize) -> Option<&Rule> {
127        self.rules.get(index).map(|(_, r)| r.as_ref())
128    }
129
130    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &Rule)> {
131        self.rules.iter().map(|(p, r)| (p.as_str(), r.as_ref()))
132    }
133
134    pub fn viable_checkers(
135        &self,
136        source: impl AsRef<str>,
137    ) -> Vec<(usize, Arc<Rule>, usize, &Checker)> {
138        let source = source.as_ref();
139
140        self.rules
141            .iter()
142            .enumerate()
143            .map(|(rule_id, (_, rule))| {
144                rule.checks()
145                    .iter()
146                    .enumerate()
147                    .filter_map(move |(i, checker)| {
148                        if checker.can_match(source) {
149                            Some((rule_id, rule.clone(), i, checker))
150                        } else {
151                            None
152                        }
153                    })
154            })
155            .flatten()
156            .collect()
157    }
158
159    pub fn is_empty(&self) -> bool {
160        self.rules.is_empty()
161    }
162
163    pub fn len(&self) -> usize {
164        self.rules.len()
165    }
166}
167
168#[derive(
169    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
170)]
171#[serde(rename_all = "lowercase")]
172pub enum Severity {
173    #[default]
174    None,
175    Low,
176    Medium,
177    High,
178    Critical,
179}
180
181impl Display for Severity {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.write_str(match self {
184            Self::None => "n/a",
185            Self::Low => "low",
186            Self::Medium => "medium",
187            Self::High => "high",
188            Self::Critical => "critical",
189        })
190    }
191}
192
193pub struct Rule {
194    id: String,
195    author: String,
196    description: String,
197    severity: Severity,
198    tags: FxHashSet<String>,
199    checks: Box<[Checker]>,
200}
201
202impl Rule {
203    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, RuleError> {
204        let path = path.as_ref();
205        let file = File::open(path).map_err(|e| RuleError::ParseFile(path.to_owned(), e.into()))?;
206        serde_yaml::from_reader(BufReader::new(file))
207            .map_err(|e| RuleError::ParseFile(path.to_owned(), e.into()))
208    }
209
210    pub fn from_str(rule: impl AsRef<str>) -> Result<Self, RuleError> {
211        serde_yaml::from_str(rule.as_ref()).map_err(RuleError::from)
212    }
213
214    pub fn id(&self) -> &str {
215        &self.id
216    }
217
218    pub fn author(&self) -> Option<&str> {
219        if self.author.is_empty() {
220            None
221        } else {
222            Some(&self.author)
223        }
224    }
225
226    pub fn description(&self) -> Option<&str> {
227        if self.description.is_empty() {
228            None
229        } else {
230            Some(&self.description)
231        }
232    }
233
234    pub fn severity(&self) -> Severity {
235        self.severity
236    }
237
238    pub fn tags(&self) -> &FxHashSet<String> {
239        &self.tags
240    }
241
242    pub fn has_tag(&self, tag: impl Borrow<str>) -> bool {
243        self.tags.contains(tag.borrow())
244    }
245
246    pub fn checks(&self) -> &[Checker] {
247        &self.checks
248    }
249}
250
251impl<'de> Deserialize<'de> for Rule {
252    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
253    where
254        D: serde::Deserializer<'de>,
255    {
256        #[derive(Deserialize)]
257        struct RuleT {
258            id: String,
259            #[serde(default)]
260            author: String,
261            #[serde(default)]
262            description: String,
263            #[serde(default)]
264            severity: Severity,
265            #[serde(default)]
266            tags: FxHashSet<String>,
267            #[serde(
268                rename = "check patterns",
269                alias = "check-patterns",
270                alias = "check pattern",
271                alias = "check-pattern"
272            )]
273            checks: OneOrMany<CheckerT>,
274        }
275
276        let rule = RuleT::deserialize(deserializer)?;
277
278        if rule.id.is_empty() {
279            return Err(<D::Error as serde::de::Error>::custom(RuleError::NoId));
280        }
281
282        let checks = Vec::try_from(rule.checks)
283            .map_err(<D::Error as serde::de::Error>::custom)?
284            .into_boxed_slice();
285
286        Ok(Rule {
287            id: rule.id,
288            author: rule.author,
289            description: rule.description,
290            severity: rule.severity,
291            tags: rule.tags,
292            checks,
293        })
294    }
295}
296
297#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
298pub enum CheckerLanguage {
299    #[serde(rename = "c")]
300    #[default]
301    C,
302    #[serde(rename = "c++")]
303    Cplusplus,
304}
305
306impl CheckerLanguage {
307    pub fn is_c(&self) -> bool {
308        matches!(self, Self::C)
309    }
310
311    pub fn is_cxx(&self) -> bool {
312        matches!(self, Self::Cplusplus)
313    }
314}
315
316pub struct Checker {
317    name: Arc<str>,
318    language: CheckerLanguage,
319    pattern: QueryTree,
320    identifiers: Box<[String]>,
321    limit: bool,
322    unique: bool,
323}
324
325impl Checker {
326    pub fn name(&self) -> &str {
327        &self.name
328    }
329
330    pub fn name_for_match(&self) -> Arc<str> {
331        self.name.clone()
332    }
333
334    pub fn language(&self) -> CheckerLanguage {
335        self.language
336    }
337
338    pub fn pattern(&self) -> &QueryTree {
339        &self.pattern
340    }
341
342    pub fn limit(&self) -> bool {
343        self.limit
344    }
345
346    pub fn unique(&self) -> bool {
347        self.unique
348    }
349
350    pub fn can_match(&self, source: &str) -> bool {
351        self.identifiers
352            .iter()
353            .all(|ident| memmem::find(source.as_ref(), ident.as_ref()).is_some())
354    }
355
356    pub fn check_match(&self, tree: &Tree, source: &str) -> Vec<QueryResult> {
357        let matches = self.pattern.matches(tree.root_node(), source);
358        if matches.is_empty() {
359            return Vec::with_capacity(0);
360        }
361
362        let check_unique = |m: &QueryResult| {
363            !self.unique || {
364                let mut seen = FxHashSet::default();
365                m.vars
366                    .keys()
367                    .filter_map(|k| m.value(k, &source))
368                    .all(|x| seen.insert(x))
369            }
370        };
371
372        let mut skip_set = FxHashSet::default();
373        let mut check_limit = |m: &QueryResult| !self.limit || skip_set.insert(m.start_offset());
374
375        matches
376            .into_iter()
377            .filter(|v| check_unique(v) && check_limit(v))
378            .collect()
379    }
380}
381
382impl<'de> Deserialize<'de> for Checker {
383    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
384    where
385        D: serde::Deserializer<'de>,
386    {
387        let checker = CheckerT::deserialize(deserializer)?
388            .try_into()
389            .map_err(<D::Error as serde::de::Error>::custom)?;
390
391        Ok(checker)
392    }
393}
394
395#[derive(Debug, Deserialize)]
396#[serde(untagged)]
397enum OneOrMany<T> {
398    Many(NonEmpty<T>),
399    One(T),
400}
401
402impl<T> From<OneOrMany<T>> for Vec<T> {
403    fn from(value: OneOrMany<T>) -> Self {
404        match value {
405            OneOrMany::One(v) => vec![v],
406            OneOrMany::Many(vs) => vs.into(),
407        }
408    }
409}
410
411#[derive(Debug, Deserialize)]
412struct CheckerT {
413    #[serde(default = "default_check_name")]
414    name: String,
415    #[serde(default)]
416    language: CheckerLanguage,
417    pattern: String,
418    #[serde(alias = "regex", default)]
419    regexes: Option<OneOrMany<String>>,
420    #[serde(default)]
421    limit: bool,
422    #[serde(default)]
423    unique: bool,
424}
425
426fn default_check_name() -> String {
427    String::from("default")
428}
429
430fn validate_checker(checker: CheckerT) -> Result<CheckerT, CheckError> {
431    if checker.name.is_empty() {
432        return Err(CheckError::NoCheckName);
433    }
434
435    Ok(checker)
436}
437
438fn build_pattern(
439    input: String,
440    constraints: &RegexMap,
441    cxx: bool,
442) -> Result<(QueryTree, HashSet<String>), CheckError> {
443    let pattern = weggli::parse_search_pattern(&input, cxx, false, Some(constraints.clone()))?;
444    let variables = pattern.variables();
445
446    Ok((pattern, variables))
447}
448
449// NOTE: this is from weggli! maybe replace with nom + regex
450fn build_regex_mapping(regexes: Option<OneOrMany<String>>) -> Result<RegexMap, CheckError> {
451    let mut result = HashMap::new();
452
453    let Some(regexes) = regexes.map(Vec::from) else {
454        return Ok(RegexMap::new(result));
455    };
456
457    for r in regexes {
458        let (var, raw_regex) = r
459            .split_once('=')
460            .ok_or_else(|| RegexError::InvalidFormat(r.to_owned()))?;
461
462        let var = var.trim();
463        let raw_regex = raw_regex.trim();
464
465        let mut normalised_var = if var.starts_with('$') {
466            var.to_owned()
467        } else {
468            format!("${var}")
469        };
470
471        let negative = normalised_var.ends_with('!');
472
473        if negative {
474            normalised_var.pop(); // remove !
475        }
476
477        let regex = Regex::new(raw_regex).map_err(RegexError::from)?;
478
479        result.insert(normalised_var, (negative, regex));
480    }
481
482    Ok(RegexMap::new(result))
483}
484
485impl TryFrom<CheckerT> for Checker {
486    type Error = CheckError;
487
488    fn try_from(c: CheckerT) -> Result<Self, Self::Error> {
489        let regexes = build_regex_mapping(c.regexes)?;
490        let (pattern, variables) = build_pattern(c.pattern.into(), &regexes, c.language.is_cxx())?;
491
492        for v in regexes.variables() {
493            if !variables.contains(v) {
494                return Err(CheckError::InvalidQueryVariable(v.to_owned()));
495            }
496        }
497
498        Ok(Self {
499            name: Arc::from(c.name),
500            language: c.language,
501            identifiers: pattern.identifiers().into_boxed_slice(),
502            pattern,
503            limit: c.limit,
504            unique: c.unique,
505        })
506    }
507}
508
509impl TryFrom<OneOrMany<CheckerT>> for Vec<Checker> {
510    type Error = RuleError;
511
512    fn try_from(value: OneOrMany<CheckerT>) -> Result<Self, Self::Error> {
513        match value {
514            OneOrMany::One(checker) => {
515                let checker = validate_checker(checker)?;
516                Ok(vec![checker.try_into()?])
517            }
518            OneOrMany::Many(checkers) => {
519                let mut names = FxHashSet::default();
520                let mut checks = Vec::new();
521
522                for checker in checkers {
523                    let checker = validate_checker(checker)?;
524
525                    if !names.insert(checker.name.to_owned()) {
526                        return Err(RuleError::MultipleChecksWithSameName);
527                    }
528
529                    checks.push(checker.try_into()?);
530                }
531
532                Ok(checks)
533            }
534        }
535    }
536}
537
538#[cfg(test)]
539mod test {
540    use super::*;
541
542    #[test]
543    fn test_rule_parse() -> Result<(), RuleError> {
544        let rule1 = r#"
545id: call-to-unbounded-copy-functions
546check pattern:
547  regex: func=^gets$
548  pattern: '{$func();}'
549"#;
550        let rule = Rule::from_str(rule1)?;
551
552        assert_eq!(rule.id(), "call-to-unbounded-copy-functions");
553        assert_eq!(rule.checks().len(), 1);
554
555        let rule2 = r#"
556id: call-to-unbounded-copy-functions
557description: call to unbounded copy functions
558severity: medium
559tags:
560- CWE-120
561- CWE-242
562- CWE-676
563check-patterns:
564- name: gets
565  regex: func=^gets$
566  pattern: |
567    { $func(); }
568
569- name: st(r|p)(cpy|cat)
570  regex: func=st(r|p)(cpy|cat)$
571  pattern: '{$func();}'
572
573- name: wc(r|p)(cpy|cat)
574  regex: func=wc(r|p)(cpy|cat)$
575  pattern: '{$func();}'
576
577- name: sprintf
578  regex: func=sprintf$
579  pattern: '{$func();}'
580
581- name: scanf
582  regex: func=scanf$
583  pattern: '{$func();}'
584"#;
585
586        let rule = Rule::from_str(rule2)?;
587
588        assert_eq!(rule.id(), "call-to-unbounded-copy-functions");
589        assert_eq!(rule.checks().len(), 5);
590
591        Ok(())
592    }
593}