sentinel_modsec/parser/
action.rs

1//! Action parsing for SecRule.
2
3use crate::error::{Error, Result};
4
5/// An action in a SecRule.
6#[derive(Debug, Clone)]
7pub enum Action {
8    /// Disruptive action (deny, block, pass, allow, redirect, drop).
9    Disruptive(DisruptiveAction),
10    /// Flow control action (chain, skip, skipAfter).
11    Flow(FlowAction),
12    /// Metadata action (id, phase, severity, msg, tag, etc.).
13    Metadata(MetadataAction),
14    /// Data action (setvar, capture, etc.).
15    Data(DataAction),
16    /// Logging action (log, nolog, auditlog, etc.).
17    Logging(LoggingAction),
18    /// Control action (ctl).
19    Control(ControlAction),
20    /// Transformation (t:xxx).
21    Transformation(String),
22}
23
24/// Disruptive actions.
25#[derive(Debug, Clone)]
26pub enum DisruptiveAction {
27    /// Deny the request (return status).
28    Deny,
29    /// Block the request.
30    Block,
31    /// Pass (continue processing).
32    Pass,
33    /// Allow (stop processing, allow request).
34    Allow,
35    /// Allow current phase.
36    AllowPhase,
37    /// Allow current request.
38    AllowRequest,
39    /// Redirect to URL.
40    Redirect(String),
41    /// Drop connection.
42    Drop,
43}
44
45/// Flow control actions.
46#[derive(Debug, Clone)]
47pub enum FlowAction {
48    /// Chain to next rule.
49    Chain,
50    /// Skip N rules.
51    Skip(u32),
52    /// Skip to marker.
53    SkipAfter(String),
54    /// Run operator on each value separately.
55    MultiMatch,
56}
57
58/// Metadata actions.
59#[derive(Debug, Clone)]
60pub enum MetadataAction {
61    /// Rule ID.
62    Id(u64),
63    /// Processing phase.
64    Phase(u8),
65    /// Severity level.
66    Severity(u8),
67    /// Message.
68    Msg(String),
69    /// Tag.
70    Tag(String),
71    /// Revision.
72    Rev(String),
73    /// Version.
74    Ver(String),
75    /// Maturity level.
76    Maturity(u8),
77    /// Accuracy level.
78    Accuracy(u8),
79    /// Log data.
80    LogData(String),
81    /// HTTP status code.
82    Status(u16),
83}
84
85/// Data actions.
86#[derive(Debug, Clone)]
87pub enum DataAction {
88    /// Set variable.
89    SetVar(SetVarSpec),
90    /// Capture regex groups.
91    Capture,
92    /// Initialize collection.
93    InitCol { collection: String, key: String },
94    /// Set UID.
95    SetUid(String),
96    /// Set SID.
97    SetSid(String),
98    /// Expire variable.
99    ExpireVar { var: String, seconds: u64 },
100    /// Deprecate variable.
101    DeprecateVar(String),
102    /// Execute script.
103    Exec(String),
104    /// Prepend response body.
105    Prepend(String),
106    /// Append response body.
107    Append(String),
108}
109
110/// SetVar specification.
111#[derive(Debug, Clone)]
112pub struct SetVarSpec {
113    /// Collection name (e.g., "tx").
114    pub collection: String,
115    /// Variable key.
116    pub key: String,
117    /// Value to set.
118    pub value: SetVarValue,
119}
120
121/// SetVar value types.
122#[derive(Debug, Clone)]
123pub enum SetVarValue {
124    /// Set to string value.
125    String(String),
126    /// Set to integer value.
127    Int(i64),
128    /// Increment by amount.
129    Increment(i64),
130    /// Decrement by amount.
131    Decrement(i64),
132    /// Delete variable.
133    Delete,
134}
135
136/// Logging actions.
137#[derive(Debug, Clone)]
138pub enum LoggingAction {
139    /// Enable logging.
140    Log,
141    /// Disable logging.
142    NoLog,
143    /// Enable audit logging.
144    AuditLog,
145    /// Disable audit logging.
146    NoAuditLog,
147    /// Sanitize matched variables.
148    SanitiseMatched,
149    /// Sanitize matched variables (alias).
150    SanitizeMatched,
151    /// Sanitize argument.
152    SanitiseArg(String),
153    /// Sanitize request header.
154    SanitiseRequestHeader(String),
155    /// Sanitize response header.
156    SanitiseResponseHeader(String),
157}
158
159/// Control actions.
160#[derive(Debug, Clone)]
161pub struct ControlAction {
162    /// Control directive.
163    pub directive: String,
164    /// Control value.
165    pub value: String,
166}
167
168/// Normalize line continuations in an action string.
169/// CRS rules often use backslash-newline to split long action lists across multiple lines.
170fn normalize_line_continuations(input: &str) -> String {
171    let mut result = String::with_capacity(input.len());
172    let mut chars = input.chars().peekable();
173
174    while let Some(c) = chars.next() {
175        if c == '\\' {
176            // Check if this is a line continuation
177            if chars.peek() == Some(&'\n') {
178                // Skip the backslash and newline
179                chars.next();
180                // Skip any leading whitespace on the next line
181                while chars.peek().map(|c| c.is_whitespace() && *c != '\n').unwrap_or(false) {
182                    chars.next();
183                }
184                continue;
185            } else if chars.peek() == Some(&'\r') {
186                // Handle Windows-style line endings
187                chars.next();
188                if chars.peek() == Some(&'\n') {
189                    chars.next();
190                }
191                // Skip any leading whitespace on the next line
192                while chars.peek().map(|c| c.is_whitespace() && *c != '\n').unwrap_or(false) {
193                    chars.next();
194                }
195                continue;
196            }
197        }
198        result.push(c);
199    }
200
201    result
202}
203
204/// Parse an action list from a string.
205pub fn parse_actions(input: &str) -> Result<Vec<Action>> {
206    // First, normalize line continuations (backslash followed by newline and optional whitespace)
207    let normalized = normalize_line_continuations(input);
208
209    let mut actions = Vec::new();
210    let mut chars = normalized.chars().peekable();
211    let mut current = String::new();
212    let mut in_quotes = false;
213    let mut quote_char = '"';
214    let mut paren_depth: u32 = 0;
215
216    while let Some(c) = chars.next() {
217        match c {
218            '"' | '\'' if !in_quotes => {
219                in_quotes = true;
220                quote_char = c;
221                current.push(c);
222            }
223            c if in_quotes && c == quote_char => {
224                in_quotes = false;
225                current.push(c);
226            }
227            '(' if !in_quotes => {
228                paren_depth += 1;
229                current.push(c);
230            }
231            ')' if !in_quotes => {
232                paren_depth = paren_depth.saturating_sub(1);
233                current.push(c);
234            }
235            ',' if !in_quotes && paren_depth == 0 => {
236                if !current.trim().is_empty() {
237                    actions.push(parse_single_action(current.trim())?);
238                }
239                current.clear();
240            }
241            _ => {
242                current.push(c);
243            }
244        }
245    }
246
247    // Don't forget the last action
248    if !current.trim().is_empty() {
249        actions.push(parse_single_action(current.trim())?);
250    }
251
252    Ok(actions)
253}
254
255/// Parse a single action.
256fn parse_single_action(input: &str) -> Result<Action> {
257    let input = input.trim();
258
259    // Check for transformation (t:xxx)
260    if input.starts_with("t:") {
261        return Ok(Action::Transformation(input[2..].to_string()));
262    }
263
264    // Split on : for actions with arguments
265    let (name, argument) = if let Some(pos) = input.find(':') {
266        let name = &input[..pos];
267        let arg = &input[pos + 1..];
268        (name.to_lowercase(), Some(arg.to_string()))
269    } else {
270        (input.to_lowercase(), None)
271    };
272
273    match name.as_str() {
274        // Disruptive actions
275        "deny" => Ok(Action::Disruptive(DisruptiveAction::Deny)),
276        "block" => Ok(Action::Disruptive(DisruptiveAction::Block)),
277        "pass" => Ok(Action::Disruptive(DisruptiveAction::Pass)),
278        "allow" => Ok(Action::Disruptive(DisruptiveAction::Allow)),
279        "drop" => Ok(Action::Disruptive(DisruptiveAction::Drop)),
280        "redirect" => {
281            let url = argument.ok_or_else(|| Error::InvalidActionArgument {
282                action: "redirect".to_string(),
283                message: "missing URL".to_string(),
284            })?;
285            Ok(Action::Disruptive(DisruptiveAction::Redirect(url)))
286        }
287
288        // Flow actions
289        "chain" => Ok(Action::Flow(FlowAction::Chain)),
290        "skip" => {
291            let count: u32 = argument
292                .as_ref()
293                .and_then(|s| s.parse().ok())
294                .ok_or_else(|| Error::InvalidActionArgument {
295                    action: "skip".to_string(),
296                    message: "invalid count".to_string(),
297                })?;
298            Ok(Action::Flow(FlowAction::Skip(count)))
299        }
300        "skipafter" => {
301            let marker = argument.ok_or_else(|| Error::InvalidActionArgument {
302                action: "skipAfter".to_string(),
303                message: "missing marker name".to_string(),
304            })?;
305            Ok(Action::Flow(FlowAction::SkipAfter(marker)))
306        }
307
308        // Metadata actions
309        "id" => {
310            let id: u64 = argument
311                .as_ref()
312                .and_then(|s| s.parse().ok())
313                .ok_or_else(|| Error::InvalidActionArgument {
314                    action: "id".to_string(),
315                    message: "invalid ID".to_string(),
316                })?;
317            Ok(Action::Metadata(MetadataAction::Id(id)))
318        }
319        "phase" => {
320            let phase: u8 = argument
321                .as_ref()
322                .and_then(|s| s.parse().ok())
323                .ok_or_else(|| Error::InvalidActionArgument {
324                    action: "phase".to_string(),
325                    message: "invalid phase".to_string(),
326                })?;
327            Ok(Action::Metadata(MetadataAction::Phase(phase)))
328        }
329        "severity" => {
330            let sev: u8 = argument
331                .as_ref()
332                .map(|s| s.trim_matches(|c| c == '\'' || c == '"'))
333                .and_then(|s| parse_severity(s))
334                .ok_or_else(|| Error::InvalidActionArgument {
335                    action: "severity".to_string(),
336                    message: "invalid severity".to_string(),
337                })?;
338            Ok(Action::Metadata(MetadataAction::Severity(sev)))
339        }
340        "msg" => {
341            let msg = argument.unwrap_or_default();
342            // Remove surrounding quotes if present
343            let msg = msg.trim_matches(|c| c == '\'' || c == '"');
344            Ok(Action::Metadata(MetadataAction::Msg(msg.to_string())))
345        }
346        "tag" => {
347            let tag = argument.unwrap_or_default();
348            let tag = tag.trim_matches(|c| c == '\'' || c == '"');
349            Ok(Action::Metadata(MetadataAction::Tag(tag.to_string())))
350        }
351        "rev" => {
352            let rev = argument.unwrap_or_default();
353            let rev = rev.trim_matches(|c| c == '\'' || c == '"');
354            Ok(Action::Metadata(MetadataAction::Rev(rev.to_string())))
355        }
356        "ver" => {
357            let ver = argument.unwrap_or_default();
358            let ver = ver.trim_matches(|c| c == '\'' || c == '"');
359            Ok(Action::Metadata(MetadataAction::Ver(ver.to_string())))
360        }
361        "maturity" => {
362            let mat: u8 = argument
363                .as_ref()
364                .and_then(|s| s.parse().ok())
365                .ok_or_else(|| Error::InvalidActionArgument {
366                    action: "maturity".to_string(),
367                    message: "invalid maturity".to_string(),
368                })?;
369            Ok(Action::Metadata(MetadataAction::Maturity(mat)))
370        }
371        "accuracy" => {
372            let acc: u8 = argument
373                .as_ref()
374                .and_then(|s| s.parse().ok())
375                .ok_or_else(|| Error::InvalidActionArgument {
376                    action: "accuracy".to_string(),
377                    message: "invalid accuracy".to_string(),
378                })?;
379            Ok(Action::Metadata(MetadataAction::Accuracy(acc)))
380        }
381        "logdata" => {
382            let data = argument.unwrap_or_default();
383            let data = data.trim_matches(|c| c == '\'' || c == '"');
384            Ok(Action::Metadata(MetadataAction::LogData(data.to_string())))
385        }
386        "status" => {
387            let status: u16 = argument
388                .as_ref()
389                .and_then(|s| s.parse().ok())
390                .ok_or_else(|| Error::InvalidActionArgument {
391                    action: "status".to_string(),
392                    message: "invalid status code".to_string(),
393                })?;
394            Ok(Action::Metadata(MetadataAction::Status(status)))
395        }
396
397        // Data actions
398        "setvar" => {
399            let spec = argument.ok_or_else(|| Error::InvalidActionArgument {
400                action: "setvar".to_string(),
401                message: "missing variable specification".to_string(),
402            })?;
403            let setvar = parse_setvar(&spec)?;
404            Ok(Action::Data(DataAction::SetVar(setvar)))
405        }
406        "capture" => Ok(Action::Data(DataAction::Capture)),
407
408        // Logging actions
409        "log" => Ok(Action::Logging(LoggingAction::Log)),
410        "nolog" => Ok(Action::Logging(LoggingAction::NoLog)),
411        "auditlog" => Ok(Action::Logging(LoggingAction::AuditLog)),
412        "noauditlog" => Ok(Action::Logging(LoggingAction::NoAuditLog)),
413        "sanitisematched" | "sanitizematched" => Ok(Action::Logging(LoggingAction::SanitiseMatched)),
414
415        // Control actions
416        "ctl" => {
417            let spec = argument.ok_or_else(|| Error::InvalidActionArgument {
418                action: "ctl".to_string(),
419                message: "missing control specification".to_string(),
420            })?;
421            let (directive, value) = if let Some(pos) = spec.find('=') {
422                (spec[..pos].to_string(), spec[pos + 1..].to_string())
423            } else {
424                (spec, String::new())
425            };
426            Ok(Action::Control(ControlAction { directive, value }))
427        }
428
429        // initcol:collection=key - initialize a persistent collection
430        "initcol" => {
431            let spec = argument.ok_or_else(|| Error::InvalidActionArgument {
432                action: "initcol".to_string(),
433                message: "missing collection specification".to_string(),
434            })?;
435            let (collection, key) = if let Some(pos) = spec.find('=') {
436                (spec[..pos].to_string(), spec[pos + 1..].to_string())
437            } else {
438                (spec, String::new())
439            };
440            Ok(Action::Data(DataAction::InitCol { collection, key }))
441        }
442
443        // setsid/setuid - set session/user ID
444        "setsid" | "setuid" => {
445            // These are used for persistent storage, we'll just acknowledge them
446            Ok(Action::Logging(LoggingAction::NoAuditLog)) // Placeholder - doesn't affect rule matching
447        }
448
449        // deprecatevar - deprecated variable handling
450        "deprecatevar" => {
451            Ok(Action::Logging(LoggingAction::NoAuditLog)) // Placeholder
452        }
453
454        // expirevar - set variable expiration
455        "expirevar" => {
456            let spec = argument.unwrap_or_default();
457            let (var, seconds) = if let Some(pos) = spec.find('=') {
458                let var = spec[..pos].to_string();
459                let secs: u64 = spec[pos + 1..].parse().unwrap_or(0);
460                (var, secs)
461            } else {
462                (spec, 0)
463            };
464            Ok(Action::Data(DataAction::ExpireVar { var, seconds }))
465        }
466
467        // multimatch - run operator multiple times for each value
468        "multimatch" => Ok(Action::Flow(FlowAction::MultiMatch)),
469
470        // exec - execute an external script (acknowledged but not executed)
471        "exec" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
472
473        // append/prepend - append/prepend to response body (acknowledged)
474        "append" | "prepend" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
475
476        // proxy - proxy request (acknowledged)
477        "proxy" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
478
479        // pause - pause processing (acknowledged)
480        "pause" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
481
482        // xmlns - XML namespace (acknowledged)
483        "xmlns" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
484
485        _ => Err(Error::UnknownAction {
486            name: name.to_string(),
487        }),
488    }
489}
490
491/// Parse a setvar specification.
492fn parse_setvar(input: &str) -> Result<SetVarSpec> {
493    let input = input.trim();
494
495    // Check for delete (!var)
496    if input.starts_with('!') {
497        let var = &input[1..];
498        let (collection, key) = parse_var_name(var)?;
499        return Ok(SetVarSpec {
500            collection,
501            key,
502            value: SetVarValue::Delete,
503        });
504    }
505
506    // Split on = for assignment
507    let (var, value_str) = if let Some(pos) = input.find('=') {
508        (&input[..pos], Some(&input[pos + 1..]))
509    } else {
510        (input, None)
511    };
512
513    let (collection, key) = parse_var_name(var)?;
514
515    let value = if let Some(val) = value_str {
516        if val.starts_with('+') {
517            // Increment
518            let amount: i64 = val[1..].parse().unwrap_or(1);
519            SetVarValue::Increment(amount)
520        } else if val.starts_with('-') {
521            // Decrement
522            let amount: i64 = val[1..].parse().unwrap_or(1);
523            SetVarValue::Decrement(amount)
524        } else if let Ok(n) = val.parse::<i64>() {
525            SetVarValue::Int(n)
526        } else {
527            SetVarValue::String(val.to_string())
528        }
529    } else {
530        SetVarValue::String("1".to_string())
531    };
532
533    Ok(SetVarSpec {
534        collection,
535        key,
536        value,
537    })
538}
539
540/// Parse a variable name into collection and key.
541fn parse_var_name(input: &str) -> Result<(String, String)> {
542    if let Some(pos) = input.find('.') {
543        Ok((input[..pos].to_lowercase(), input[pos + 1..].to_string()))
544    } else {
545        // Default to tx collection
546        Ok(("tx".to_string(), input.to_string()))
547    }
548}
549
550/// Parse severity from string or number.
551fn parse_severity(s: &str) -> Option<u8> {
552    // Try numeric first
553    if let Ok(n) = s.parse::<u8>() {
554        return Some(n);
555    }
556
557    // Try named severities
558    match s.to_lowercase().as_str() {
559        "emergency" => Some(0),
560        "alert" => Some(1),
561        "critical" => Some(2),
562        "error" => Some(3),
563        "warning" => Some(4),
564        "notice" => Some(5),
565        "info" => Some(6),
566        "debug" => Some(7),
567        _ => None,
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn test_parse_simple_actions() {
577        let actions = parse_actions("id:1,deny,status:403").unwrap();
578        assert_eq!(actions.len(), 3);
579    }
580
581    #[test]
582    fn test_parse_action_with_msg() {
583        let actions = parse_actions("id:1,msg:'Hello world',deny").unwrap();
584        assert_eq!(actions.len(), 3);
585    }
586
587    #[test]
588    fn test_parse_setvar() {
589        let actions = parse_actions("setvar:tx.score=+5").unwrap();
590        assert_eq!(actions.len(), 1);
591        match &actions[0] {
592            Action::Data(DataAction::SetVar(spec)) => {
593                assert_eq!(spec.collection, "tx");
594                assert_eq!(spec.key, "score");
595                assert!(matches!(spec.value, SetVarValue::Increment(5)));
596            }
597            _ => panic!("expected SetVar"),
598        }
599    }
600
601    #[test]
602    fn test_parse_chain() {
603        let actions = parse_actions("id:1,phase:2,chain").unwrap();
604        assert!(actions.iter().any(|a| matches!(a, Action::Flow(FlowAction::Chain))));
605    }
606
607    #[test]
608    fn test_parse_transformation() {
609        let actions = parse_actions("id:1,t:lowercase,t:urlDecode").unwrap();
610        let transforms: Vec<_> = actions
611            .iter()
612            .filter(|a| matches!(a, Action::Transformation(_)))
613            .collect();
614        assert_eq!(transforms.len(), 2);
615    }
616}