Skip to main content

zentinel_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    /// Raw right-hand side containing `%{...}` macros, resolved at apply time.
135    Macro(String),
136}
137
138/// Logging actions.
139#[derive(Debug, Clone)]
140pub enum LoggingAction {
141    /// Enable logging.
142    Log,
143    /// Disable logging.
144    NoLog,
145    /// Enable audit logging.
146    AuditLog,
147    /// Disable audit logging.
148    NoAuditLog,
149    /// Sanitize matched variables.
150    SanitiseMatched,
151    /// Sanitize matched variables (alias).
152    SanitizeMatched,
153    /// Sanitize argument.
154    SanitiseArg(String),
155    /// Sanitize request header.
156    SanitiseRequestHeader(String),
157    /// Sanitize response header.
158    SanitiseResponseHeader(String),
159}
160
161/// Control actions.
162#[derive(Debug, Clone)]
163pub struct ControlAction {
164    /// Control directive.
165    pub directive: String,
166    /// Control value.
167    pub value: String,
168}
169
170/// Normalize line continuations in an action string.
171/// CRS rules often use backslash-newline to split long action lists across multiple lines.
172fn normalize_line_continuations(input: &str) -> String {
173    let mut result = String::with_capacity(input.len());
174    let mut chars = input.chars().peekable();
175
176    while let Some(c) = chars.next() {
177        if c == '\\' {
178            // Check if this is a line continuation
179            if chars.peek() == Some(&'\n') {
180                // Skip the backslash and newline
181                chars.next();
182                // Skip any leading whitespace on the next line
183                while chars.peek().map(|c| c.is_whitespace() && *c != '\n').unwrap_or(false) {
184                    chars.next();
185                }
186                continue;
187            } else if chars.peek() == Some(&'\r') {
188                // Handle Windows-style line endings
189                chars.next();
190                if chars.peek() == Some(&'\n') {
191                    chars.next();
192                }
193                // Skip any leading whitespace on the next line
194                while chars.peek().map(|c| c.is_whitespace() && *c != '\n').unwrap_or(false) {
195                    chars.next();
196                }
197                continue;
198            }
199        }
200        result.push(c);
201    }
202
203    result
204}
205
206/// Parse an action list from a string.
207pub fn parse_actions(input: &str) -> Result<Vec<Action>> {
208    // First, normalize line continuations (backslash followed by newline and optional whitespace)
209    let normalized = normalize_line_continuations(input);
210
211    let mut actions = Vec::new();
212    let mut chars = normalized.chars().peekable();
213    let mut current = String::new();
214    let mut in_quotes = false;
215    let mut quote_char = '"';
216    let mut paren_depth: u32 = 0;
217
218    while let Some(c) = chars.next() {
219        match c {
220            '"' | '\'' if !in_quotes => {
221                in_quotes = true;
222                quote_char = c;
223                current.push(c);
224            }
225            c if in_quotes && c == quote_char => {
226                in_quotes = false;
227                current.push(c);
228            }
229            '(' if !in_quotes => {
230                paren_depth += 1;
231                current.push(c);
232            }
233            ')' if !in_quotes => {
234                paren_depth = paren_depth.saturating_sub(1);
235                current.push(c);
236            }
237            ',' if !in_quotes && paren_depth == 0 => {
238                if !current.trim().is_empty() {
239                    actions.push(parse_single_action(current.trim())?);
240                }
241                current.clear();
242            }
243            _ => {
244                current.push(c);
245            }
246        }
247    }
248
249    // Don't forget the last action
250    if !current.trim().is_empty() {
251        actions.push(parse_single_action(current.trim())?);
252    }
253
254    Ok(actions)
255}
256
257/// Parse a single action.
258fn parse_single_action(input: &str) -> Result<Action> {
259    let input = input.trim();
260
261    // Check for transformation (t:xxx)
262    if input.starts_with("t:") {
263        return Ok(Action::Transformation(input[2..].to_string()));
264    }
265
266    // Split on : for actions with arguments
267    let (name, argument) = if let Some(pos) = input.find(':') {
268        let name = &input[..pos];
269        let arg = &input[pos + 1..];
270        (name.to_lowercase(), Some(arg.to_string()))
271    } else {
272        (input.to_lowercase(), None)
273    };
274
275    match name.as_str() {
276        // Disruptive actions
277        "deny" => Ok(Action::Disruptive(DisruptiveAction::Deny)),
278        "block" => Ok(Action::Disruptive(DisruptiveAction::Block)),
279        "pass" => Ok(Action::Disruptive(DisruptiveAction::Pass)),
280        "allow" => Ok(Action::Disruptive(DisruptiveAction::Allow)),
281        "drop" => Ok(Action::Disruptive(DisruptiveAction::Drop)),
282        "redirect" => {
283            let url = argument.ok_or_else(|| Error::InvalidActionArgument {
284                action: "redirect".to_string(),
285                message: "missing URL".to_string(),
286            })?;
287            Ok(Action::Disruptive(DisruptiveAction::Redirect(url)))
288        }
289
290        // Flow actions
291        "chain" => Ok(Action::Flow(FlowAction::Chain)),
292        "skip" => {
293            let count: u32 = argument
294                .as_ref()
295                .and_then(|s| s.parse().ok())
296                .ok_or_else(|| Error::InvalidActionArgument {
297                    action: "skip".to_string(),
298                    message: "invalid count".to_string(),
299                })?;
300            Ok(Action::Flow(FlowAction::Skip(count)))
301        }
302        "skipafter" => {
303            let marker = argument.ok_or_else(|| Error::InvalidActionArgument {
304                action: "skipAfter".to_string(),
305                message: "missing marker name".to_string(),
306            })?;
307            Ok(Action::Flow(FlowAction::SkipAfter(marker)))
308        }
309
310        // Metadata actions
311        "id" => {
312            let id: u64 = argument
313                .as_ref()
314                .and_then(|s| s.parse().ok())
315                .ok_or_else(|| Error::InvalidActionArgument {
316                    action: "id".to_string(),
317                    message: "invalid ID".to_string(),
318                })?;
319            Ok(Action::Metadata(MetadataAction::Id(id)))
320        }
321        "phase" => {
322            let phase: u8 = argument
323                .as_ref()
324                .and_then(|s| s.parse().ok())
325                .ok_or_else(|| Error::InvalidActionArgument {
326                    action: "phase".to_string(),
327                    message: "invalid phase".to_string(),
328                })?;
329            Ok(Action::Metadata(MetadataAction::Phase(phase)))
330        }
331        "severity" => {
332            let sev: u8 = argument
333                .as_ref()
334                .map(|s| s.trim_matches(|c| c == '\'' || c == '"'))
335                .and_then(|s| parse_severity(s))
336                .ok_or_else(|| Error::InvalidActionArgument {
337                    action: "severity".to_string(),
338                    message: "invalid severity".to_string(),
339                })?;
340            Ok(Action::Metadata(MetadataAction::Severity(sev)))
341        }
342        "msg" => {
343            let msg = argument.unwrap_or_default();
344            // Remove surrounding quotes if present
345            let msg = msg.trim_matches(|c| c == '\'' || c == '"');
346            Ok(Action::Metadata(MetadataAction::Msg(msg.to_string())))
347        }
348        "tag" => {
349            let tag = argument.unwrap_or_default();
350            let tag = tag.trim_matches(|c| c == '\'' || c == '"');
351            Ok(Action::Metadata(MetadataAction::Tag(tag.to_string())))
352        }
353        "rev" => {
354            let rev = argument.unwrap_or_default();
355            let rev = rev.trim_matches(|c| c == '\'' || c == '"');
356            Ok(Action::Metadata(MetadataAction::Rev(rev.to_string())))
357        }
358        "ver" => {
359            let ver = argument.unwrap_or_default();
360            let ver = ver.trim_matches(|c| c == '\'' || c == '"');
361            Ok(Action::Metadata(MetadataAction::Ver(ver.to_string())))
362        }
363        "maturity" => {
364            let mat: u8 = argument
365                .as_ref()
366                .and_then(|s| s.parse().ok())
367                .ok_or_else(|| Error::InvalidActionArgument {
368                    action: "maturity".to_string(),
369                    message: "invalid maturity".to_string(),
370                })?;
371            Ok(Action::Metadata(MetadataAction::Maturity(mat)))
372        }
373        "accuracy" => {
374            let acc: u8 = argument
375                .as_ref()
376                .and_then(|s| s.parse().ok())
377                .ok_or_else(|| Error::InvalidActionArgument {
378                    action: "accuracy".to_string(),
379                    message: "invalid accuracy".to_string(),
380                })?;
381            Ok(Action::Metadata(MetadataAction::Accuracy(acc)))
382        }
383        "logdata" => {
384            let data = argument.unwrap_or_default();
385            let data = data.trim_matches(|c| c == '\'' || c == '"');
386            Ok(Action::Metadata(MetadataAction::LogData(data.to_string())))
387        }
388        "status" => {
389            let status: u16 = argument
390                .as_ref()
391                .and_then(|s| s.parse().ok())
392                .ok_or_else(|| Error::InvalidActionArgument {
393                    action: "status".to_string(),
394                    message: "invalid status code".to_string(),
395                })?;
396            Ok(Action::Metadata(MetadataAction::Status(status)))
397        }
398
399        // Data actions
400        "setvar" => {
401            let spec = argument.ok_or_else(|| Error::InvalidActionArgument {
402                action: "setvar".to_string(),
403                message: "missing variable specification".to_string(),
404            })?;
405            let setvar = parse_setvar(&spec)?;
406            Ok(Action::Data(DataAction::SetVar(setvar)))
407        }
408        "capture" => Ok(Action::Data(DataAction::Capture)),
409
410        // Logging actions
411        "log" => Ok(Action::Logging(LoggingAction::Log)),
412        "nolog" => Ok(Action::Logging(LoggingAction::NoLog)),
413        "auditlog" => Ok(Action::Logging(LoggingAction::AuditLog)),
414        "noauditlog" => Ok(Action::Logging(LoggingAction::NoAuditLog)),
415        "sanitisematched" | "sanitizematched" => Ok(Action::Logging(LoggingAction::SanitiseMatched)),
416
417        // Control actions
418        "ctl" => {
419            let spec = argument.ok_or_else(|| Error::InvalidActionArgument {
420                action: "ctl".to_string(),
421                message: "missing control specification".to_string(),
422            })?;
423            let (directive, value) = if let Some(pos) = spec.find('=') {
424                (spec[..pos].to_string(), spec[pos + 1..].to_string())
425            } else {
426                (spec, String::new())
427            };
428            Ok(Action::Control(ControlAction { directive, value }))
429        }
430
431        // initcol:collection=key - initialize a persistent collection
432        "initcol" => {
433            let spec = argument.ok_or_else(|| Error::InvalidActionArgument {
434                action: "initcol".to_string(),
435                message: "missing collection specification".to_string(),
436            })?;
437            let (collection, key) = if let Some(pos) = spec.find('=') {
438                (spec[..pos].to_string(), spec[pos + 1..].to_string())
439            } else {
440                (spec, String::new())
441            };
442            Ok(Action::Data(DataAction::InitCol { collection, key }))
443        }
444
445        // setsid/setuid - set session/user ID
446        "setsid" | "setuid" => {
447            // These are used for persistent storage, we'll just acknowledge them
448            Ok(Action::Logging(LoggingAction::NoAuditLog)) // Placeholder - doesn't affect rule matching
449        }
450
451        // deprecatevar - deprecated variable handling
452        "deprecatevar" => {
453            Ok(Action::Logging(LoggingAction::NoAuditLog)) // Placeholder
454        }
455
456        // expirevar - set variable expiration
457        "expirevar" => {
458            let spec = argument.unwrap_or_default();
459            let (var, seconds) = if let Some(pos) = spec.find('=') {
460                let var = spec[..pos].to_string();
461                let secs: u64 = spec[pos + 1..].parse().unwrap_or(0);
462                (var, secs)
463            } else {
464                (spec, 0)
465            };
466            Ok(Action::Data(DataAction::ExpireVar { var, seconds }))
467        }
468
469        // multimatch - run operator multiple times for each value
470        "multimatch" => Ok(Action::Flow(FlowAction::MultiMatch)),
471
472        // exec - execute an external script (acknowledged but not executed)
473        "exec" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
474
475        // append/prepend - append/prepend to response body (acknowledged)
476        "append" | "prepend" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
477
478        // proxy - proxy request (acknowledged)
479        "proxy" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
480
481        // pause - pause processing (acknowledged)
482        "pause" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
483
484        // xmlns - XML namespace (acknowledged)
485        "xmlns" => Ok(Action::Logging(LoggingAction::NoAuditLog)), // Placeholder
486
487        _ => Err(Error::UnknownAction {
488            name: name.to_string(),
489        }),
490    }
491}
492
493/// Parse a setvar specification.
494fn parse_setvar(input: &str) -> Result<SetVarSpec> {
495    let input = input.trim();
496    // CRS writes setvar specs in quoted form, e.g. setvar:'tx.score=+5'.
497    // Strip a matching pair of surrounding quotes so the value parses correctly.
498    let input = if input.len() >= 2
499        && ((input.starts_with('\'') && input.ends_with('\''))
500            || (input.starts_with('"') && input.ends_with('"')))
501    {
502        &input[1..input.len() - 1]
503    } else {
504        input
505    };
506
507    // Check for delete (!var)
508    if input.starts_with('!') {
509        let var = &input[1..];
510        let (collection, key) = parse_var_name(var)?;
511        return Ok(SetVarSpec {
512            collection,
513            key,
514            value: SetVarValue::Delete,
515        });
516    }
517
518    // Split on = for assignment
519    let (var, value_str) = if let Some(pos) = input.find('=') {
520        (&input[..pos], Some(&input[pos + 1..]))
521    } else {
522        (input, None)
523    };
524
525    let (collection, key) = parse_var_name(var)?;
526
527    let value = if let Some(val) = value_str {
528        if val.contains("%{") {
529            // Contains a macro (e.g. +%{tx.critical_anomaly_score}); defer the
530            // sign/value interpretation until the macro is expanded at apply time.
531            SetVarValue::Macro(val.to_string())
532        } else if val.starts_with('+') {
533            // Increment
534            let amount: i64 = val[1..].parse().unwrap_or(1);
535            SetVarValue::Increment(amount)
536        } else if val.starts_with('-') {
537            // Decrement
538            let amount: i64 = val[1..].parse().unwrap_or(1);
539            SetVarValue::Decrement(amount)
540        } else if let Ok(n) = val.parse::<i64>() {
541            SetVarValue::Int(n)
542        } else {
543            SetVarValue::String(val.to_string())
544        }
545    } else {
546        SetVarValue::String("1".to_string())
547    };
548
549    Ok(SetVarSpec {
550        collection,
551        key,
552        value,
553    })
554}
555
556/// Parse a variable name into collection and key.
557fn parse_var_name(input: &str) -> Result<(String, String)> {
558    if let Some(pos) = input.find('.') {
559        Ok((input[..pos].to_lowercase(), input[pos + 1..].to_string()))
560    } else {
561        // Default to tx collection
562        Ok(("tx".to_string(), input.to_string()))
563    }
564}
565
566/// Parse severity from string or number.
567fn parse_severity(s: &str) -> Option<u8> {
568    // Try numeric first
569    if let Ok(n) = s.parse::<u8>() {
570        return Some(n);
571    }
572
573    // Try named severities
574    match s.to_lowercase().as_str() {
575        "emergency" => Some(0),
576        "alert" => Some(1),
577        "critical" => Some(2),
578        "error" => Some(3),
579        "warning" => Some(4),
580        "notice" => Some(5),
581        "info" => Some(6),
582        "debug" => Some(7),
583        _ => None,
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_parse_simple_actions() {
593        let actions = parse_actions("id:1,deny,status:403").unwrap();
594        assert_eq!(actions.len(), 3);
595    }
596
597    #[test]
598    fn test_parse_action_with_msg() {
599        let actions = parse_actions("id:1,msg:'Hello world',deny").unwrap();
600        assert_eq!(actions.len(), 3);
601    }
602
603    #[test]
604    fn test_parse_setvar() {
605        let actions = parse_actions("setvar:tx.score=+5").unwrap();
606        assert_eq!(actions.len(), 1);
607        match &actions[0] {
608            Action::Data(DataAction::SetVar(spec)) => {
609                assert_eq!(spec.collection, "tx");
610                assert_eq!(spec.key, "score");
611                assert!(matches!(spec.value, SetVarValue::Increment(5)));
612            }
613            _ => panic!("expected SetVar"),
614        }
615    }
616
617    #[test]
618    fn test_parse_setvar_quoted_increment() {
619        // CRS form: setvar:'tx.anomaly_score=+5' must increment by 5, not 1.
620        let actions = parse_actions("setvar:'tx.anomaly_score=+5'").unwrap();
621        match &actions[0] {
622            Action::Data(DataAction::SetVar(spec)) => {
623                assert_eq!(spec.key, "anomaly_score");
624                assert!(matches!(spec.value, SetVarValue::Increment(5)),
625                    "expected Increment(5), got {:?}", spec.value);
626            }
627            _ => panic!("expected SetVar"),
628        }
629    }
630
631    #[test]
632    fn test_parse_setvar_quoted_set() {
633        let actions = parse_actions("setvar:'tx.anomaly_score=7'").unwrap();
634        match &actions[0] {
635            Action::Data(DataAction::SetVar(spec)) => {
636                assert_eq!(spec.key, "anomaly_score");
637                assert!(matches!(spec.value, SetVarValue::Int(7)),
638                    "expected Int(7), got {:?}", spec.value);
639            }
640            _ => panic!("expected SetVar"),
641        }
642    }
643
644    #[test]
645    fn test_parse_chain() {
646        let actions = parse_actions("id:1,phase:2,chain").unwrap();
647        assert!(actions.iter().any(|a| matches!(a, Action::Flow(FlowAction::Chain))));
648    }
649
650    #[test]
651    fn test_parse_transformation() {
652        let actions = parse_actions("id:1,t:lowercase,t:urlDecode").unwrap();
653        let transforms: Vec<_> = actions
654            .iter()
655            .filter(|a| matches!(a, Action::Transformation(_)))
656            .collect();
657        assert_eq!(transforms.len(), 2);
658    }
659}