sentinel_modsec/parser/
mod.rs

1//! SecRule parser module.
2//!
3//! This module handles parsing of ModSecurity configuration directives including:
4//! - SecRule: The main rule directive
5//! - SecAction: Actions without matching
6//! - SecMarker: Named markers for skipAfter
7//! - SecRuleEngine: Enable/disable rule processing
8//! - Include: File inclusion
9//!
10//! ## SecRule Syntax
11//!
12//! ```text
13//! SecRule VARIABLES "OPERATOR" "ACTIONS"
14//! ```
15//!
16//! Where:
17//! - VARIABLES: Comma-separated list of variables to inspect
18//! - OPERATOR: Pattern to match (e.g., @rx, @contains)
19//! - ACTIONS: Comma-separated list of actions (e.g., id:1,deny,log)
20
21mod lexer;
22mod directive;
23mod variable;
24mod operator;
25mod action;
26
27pub use lexer::{Lexer, Token, TokenKind};
28pub use directive::{Directive, SecRule, SecAction, SecMarker, RuleEngineMode};
29pub use variable::{VariableSpec, VariableName, Selection};
30pub use operator::{OperatorSpec, OperatorName};
31pub use action::{Action, DisruptiveAction, FlowAction, MetadataAction, DataAction, LoggingAction, ControlAction, SetVarSpec, SetVarValue, parse_actions};
32
33use crate::error::{Error, Result, SourceLocation};
34use std::path::Path;
35
36/// Parser for ModSecurity configuration files.
37pub struct Parser {
38    /// Parsed directives.
39    directives: Vec<Directive>,
40    /// Current source location for error reporting.
41    location: SourceLocation,
42    /// Default actions to apply to rules.
43    default_actions: Vec<Action>,
44}
45
46impl Parser {
47    /// Create a new parser.
48    pub fn new() -> Self {
49        Self {
50            directives: Vec::new(),
51            location: SourceLocation::default(),
52            default_actions: Vec::new(),
53        }
54    }
55
56    /// Parse a configuration string.
57    pub fn parse(&mut self, input: &str) -> Result<()> {
58        self.parse_with_location(input, None)
59    }
60
61    /// Parse a configuration string with file location.
62    pub fn parse_with_location(&mut self, input: &str, file: Option<&Path>) -> Result<()> {
63        self.location.file = file.map(|p| p.to_path_buf());
64        self.location.line = 1;
65        self.location.column = 1;
66
67        let mut lexer = Lexer::new(input);
68
69        while let Some(token) = lexer.next_token() {
70            self.location.line = token.line;
71            self.location.column = token.column;
72
73            match token.kind {
74                TokenKind::Directive(name) => {
75                    let directive = self.parse_directive(&name, &mut lexer)?;
76                    self.directives.push(directive);
77                }
78                TokenKind::Comment => {
79                    // Skip comments
80                }
81                TokenKind::Newline => {
82                    // Skip blank lines
83                }
84                _ => {
85                    return Err(Error::parse(
86                        format!("unexpected token: {:?}", token.kind),
87                        self.location.to_string(),
88                    ));
89                }
90            }
91        }
92
93        Ok(())
94    }
95
96    /// Parse a configuration file.
97    pub fn parse_file(&mut self, path: &Path) -> Result<()> {
98        let content = std::fs::read_to_string(path).map_err(|e| Error::RuleFileLoad {
99            path: path.to_path_buf(),
100            source: e,
101        })?;
102        self.parse_with_location(&content, Some(path))
103    }
104
105    /// Parse files matching a glob pattern.
106    pub fn parse_glob(&mut self, pattern: &str) -> Result<()> {
107        let paths = glob::glob(pattern)
108            .map_err(|e| Error::parse(format!("invalid glob pattern: {}", e), pattern))?;
109
110        for entry in paths {
111            match entry {
112                Ok(path) => {
113                    if path.is_file() {
114                        self.parse_file(&path)?;
115                    }
116                }
117                Err(e) => {
118                    tracing::warn!(error = %e, "error reading glob entry");
119                }
120            }
121        }
122
123        Ok(())
124    }
125
126    /// Get the parsed directives.
127    pub fn into_directives(self) -> Vec<Directive> {
128        self.directives
129    }
130
131    /// Get a reference to the parsed directives.
132    pub fn directives(&self) -> &[Directive] {
133        &self.directives
134    }
135
136    /// Parse a directive starting from the directive name.
137    fn parse_directive(&mut self, name: &str, lexer: &mut Lexer) -> Result<Directive> {
138        match name.to_lowercase().as_str() {
139            "secrule" => self.parse_secrule(lexer),
140            "secaction" => self.parse_secaction(lexer),
141            "secmarker" => self.parse_secmarker(lexer),
142            "secruleengine" => self.parse_secruleengine(lexer),
143            "secdefaultaction" => self.parse_secdefaultaction(lexer),
144            "secruleremovebyid" => self.parse_secruleremovebyid(lexer),
145            "secrequestbodyaccess" => self.parse_boolean_directive(lexer, "SecRequestBodyAccess"),
146            "secresponsebodyaccess" => self.parse_boolean_directive(lexer, "SecResponseBodyAccess"),
147            "include" => self.parse_include(lexer),
148            _ => {
149                // Skip unknown directives with a warning
150                tracing::warn!(
151                    directive = name,
152                    location = %self.location,
153                    "unknown directive, skipping"
154                );
155                self.skip_to_end_of_line(lexer);
156                Ok(Directive::Unknown(name.to_string()))
157            }
158        }
159    }
160
161    /// Parse a SecRule directive.
162    fn parse_secrule(&mut self, lexer: &mut Lexer) -> Result<Directive> {
163        // Parse variables
164        let variables_str = self.expect_argument(lexer, "SecRule variables")?;
165        let variables = variable::parse_variables(&variables_str)?;
166
167        // Parse operator
168        let operator_str = self.expect_quoted_argument(lexer, "SecRule operator")?;
169        let operator = operator::parse_operator(&operator_str)?;
170
171        // Parse actions (optional)
172        let actions = if self.peek_quoted(lexer) {
173            let actions_str = self.expect_quoted_argument(lexer, "SecRule actions")?;
174            let mut actions = action::parse_actions(&actions_str)?;
175            // Apply default actions
176            actions = self.merge_default_actions(actions);
177            actions
178        } else {
179            self.default_actions.clone()
180        };
181
182        Ok(Directive::SecRule(SecRule {
183            variables,
184            operator,
185            actions,
186            location: self.location.clone(),
187        }))
188    }
189
190    /// Parse a SecAction directive.
191    fn parse_secaction(&mut self, lexer: &mut Lexer) -> Result<Directive> {
192        let actions_str = self.expect_quoted_argument(lexer, "SecAction")?;
193        let actions = action::parse_actions(&actions_str)?;
194
195        Ok(Directive::SecAction(SecAction {
196            actions,
197            location: self.location.clone(),
198        }))
199    }
200
201    /// Parse a SecMarker directive.
202    fn parse_secmarker(&mut self, lexer: &mut Lexer) -> Result<Directive> {
203        let name = self.expect_argument(lexer, "SecMarker name")?;
204        Ok(Directive::SecMarker(SecMarker { name }))
205    }
206
207    /// Parse a SecRuleEngine directive.
208    fn parse_secruleengine(&mut self, lexer: &mut Lexer) -> Result<Directive> {
209        let mode_str = self.expect_argument(lexer, "SecRuleEngine mode")?;
210        let mode = match mode_str.to_lowercase().as_str() {
211            "on" => RuleEngineMode::On,
212            "off" => RuleEngineMode::Off,
213            "detectiononly" => RuleEngineMode::DetectionOnly,
214            _ => {
215                return Err(Error::parse(
216                    format!("invalid SecRuleEngine mode: {}", mode_str),
217                    self.location.to_string(),
218                ));
219            }
220        };
221        Ok(Directive::SecRuleEngine(mode))
222    }
223
224    /// Parse a SecDefaultAction directive.
225    fn parse_secdefaultaction(&mut self, lexer: &mut Lexer) -> Result<Directive> {
226        let actions_str = self.expect_quoted_argument(lexer, "SecDefaultAction")?;
227        let actions = action::parse_actions(&actions_str)?;
228        self.default_actions = actions.clone();
229        Ok(Directive::SecDefaultAction(actions))
230    }
231
232    /// Parse a SecRuleRemoveById directive.
233    fn parse_secruleremovebyid(&mut self, lexer: &mut Lexer) -> Result<Directive> {
234        let ids_str = self.expect_argument(lexer, "SecRuleRemoveById")?;
235        let ids: Vec<u64> = ids_str
236            .split_whitespace()
237            .filter_map(|s| s.parse().ok())
238            .collect();
239        Ok(Directive::SecRuleRemoveById(ids))
240    }
241
242    /// Parse a boolean directive (On/Off).
243    fn parse_boolean_directive(&mut self, lexer: &mut Lexer, name: &str) -> Result<Directive> {
244        let value_str = self.expect_argument(lexer, name)?;
245        let value = match value_str.to_lowercase().as_str() {
246            "on" => true,
247            "off" => false,
248            _ => {
249                return Err(Error::parse(
250                    format!("invalid {} value: {} (expected On/Off)", name, value_str),
251                    self.location.to_string(),
252                ));
253            }
254        };
255
256        match name {
257            "SecRequestBodyAccess" => Ok(Directive::SecRequestBodyAccess(value)),
258            "SecResponseBodyAccess" => Ok(Directive::SecResponseBodyAccess(value)),
259            _ => Ok(Directive::Unknown(name.to_string())),
260        }
261    }
262
263    /// Parse an Include directive.
264    fn parse_include(&mut self, lexer: &mut Lexer) -> Result<Directive> {
265        let path = self.expect_argument(lexer, "Include path")?;
266
267        // Resolve relative paths
268        let resolved_path = if let Some(ref base) = self.location.file {
269            if let Some(parent) = base.parent() {
270                let full_path = parent.join(&path);
271                if full_path.exists() {
272                    full_path.to_string_lossy().to_string()
273                } else {
274                    path
275                }
276            } else {
277                path
278            }
279        } else {
280            path
281        };
282
283        // Parse the included file(s)
284        self.parse_glob(&resolved_path)?;
285
286        Ok(Directive::Include(resolved_path.into()))
287    }
288
289    /// Expect an unquoted argument.
290    fn expect_argument(&mut self, lexer: &mut Lexer, context: &str) -> Result<String> {
291        lexer.skip_whitespace();
292
293        match lexer.next_token() {
294            Some(token) => match token.kind {
295                TokenKind::Word(s) | TokenKind::QuotedString(s) => Ok(s),
296                _ => Err(Error::parse(
297                    format!("expected {} but got {:?}", context, token.kind),
298                    self.location.to_string(),
299                )),
300            },
301            None => Err(Error::parse(
302                format!("expected {} but got end of input", context),
303                self.location.to_string(),
304            )),
305        }
306    }
307
308    /// Expect a quoted argument.
309    fn expect_quoted_argument(&mut self, lexer: &mut Lexer, context: &str) -> Result<String> {
310        lexer.skip_whitespace();
311
312        match lexer.next_token() {
313            Some(token) => match token.kind {
314                TokenKind::QuotedString(s) => Ok(s),
315                _ => Err(Error::parse(
316                    format!("expected quoted {} but got {:?}", context, token.kind),
317                    self.location.to_string(),
318                )),
319            },
320            None => Err(Error::parse(
321                format!("expected quoted {} but got end of input", context),
322                self.location.to_string(),
323            )),
324        }
325    }
326
327    /// Check if next token is a quoted string.
328    fn peek_quoted(&self, lexer: &mut Lexer) -> bool {
329        lexer.skip_whitespace();
330        lexer.peek().map(|c| c == '"' || c == '\'').unwrap_or(false)
331    }
332
333    /// Skip to end of current line.
334    fn skip_to_end_of_line(&self, lexer: &mut Lexer) {
335        while let Some(token) = lexer.next_token() {
336            if matches!(token.kind, TokenKind::Newline) {
337                break;
338            }
339        }
340    }
341
342    /// Merge default actions with rule-specific actions.
343    fn merge_default_actions(&self, rule_actions: Vec<Action>) -> Vec<Action> {
344        // Rule actions override defaults
345        let mut result = self.default_actions.clone();
346        for action in rule_actions {
347            // Remove any existing action of the same specific type
348            // (need to compare both outer and inner discriminants for nested enums)
349            result.retain(|a| !actions_same_type(a, &action));
350            result.push(action);
351        }
352        result
353    }
354}
355
356impl Default for Parser {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362/// Check if two actions are of the same specific type (including inner variants).
363fn actions_same_type(a: &Action, b: &Action) -> bool {
364    match (a, b) {
365        // For Metadata, compare inner variants
366        (Action::Metadata(ma), Action::Metadata(mb)) => {
367            std::mem::discriminant(ma) == std::mem::discriminant(mb)
368        }
369        // For other action types, compare outer variants
370        _ => std::mem::discriminant(a) == std::mem::discriminant(b),
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_parse_simple_rule() {
380        let mut parser = Parser::new();
381        parser
382            .parse(r#"SecRule REQUEST_URI "@contains /admin" "id:1,deny,status:403""#)
383            .unwrap();
384
385        assert_eq!(parser.directives.len(), 1);
386        match &parser.directives[0] {
387            Directive::SecRule(rule) => {
388                assert_eq!(rule.variables.len(), 1);
389                assert_eq!(rule.variables[0].name, VariableName::RequestUri);
390            }
391            _ => panic!("expected SecRule"),
392        }
393    }
394
395    #[test]
396    fn test_parse_secruleengine() {
397        let mut parser = Parser::new();
398        parser.parse("SecRuleEngine On").unwrap();
399
400        assert_eq!(parser.directives.len(), 1);
401        match &parser.directives[0] {
402            Directive::SecRuleEngine(mode) => {
403                assert_eq!(*mode, RuleEngineMode::On);
404            }
405            _ => panic!("expected SecRuleEngine"),
406        }
407    }
408}