mod lexer;
mod directive;
mod variable;
mod operator;
mod action;
pub use lexer::{Lexer, Token, TokenKind};
pub use directive::{
Directive, RuleEngineMode, RuleIdSelector, SecAction, SecMarker, SecRule, UpdateTargetById,
};
pub use variable::{VariableSpec, VariableName, Selection, XmlTarget};
pub(crate) use variable::parse_single_variable;
pub use operator::{OperatorSpec, OperatorName};
pub use action::{Action, DisruptiveAction, FlowAction, MetadataAction, DataAction, LoggingAction, ControlAction, SetVarSpec, SetVarValue, parse_actions};
use crate::error::{Error, Result, SourceLocation};
use std::collections::HashMap;
use std::path::Path;
pub struct Parser {
directives: Vec<Directive>,
location: SourceLocation,
default_actions: HashMap<u8, Vec<Action>>,
}
impl Parser {
pub fn new() -> Self {
Self {
directives: Vec::new(),
location: SourceLocation::default(),
default_actions: HashMap::new(),
}
}
pub fn parse(&mut self, input: &str) -> Result<()> {
self.parse_with_location(input, None)
}
pub fn parse_with_location(&mut self, input: &str, file: Option<&Path>) -> Result<()> {
self.location.file = file.map(|p| p.to_path_buf());
self.location.line = 1;
self.location.column = 1;
let mut lexer = Lexer::new(input);
while let Some(token) = lexer.next_token() {
self.location.line = token.line;
self.location.column = token.column;
match token.kind {
TokenKind::Directive(name) => {
let directive = self.parse_directive(&name, &mut lexer)?;
self.directives.push(directive);
}
TokenKind::Comment => {
}
TokenKind::Newline => {
}
_ => {
return Err(Error::parse(
format!("unexpected token: {:?}", token.kind),
self.location.to_string(),
));
}
}
}
Ok(())
}
pub fn parse_file(&mut self, path: &Path) -> Result<()> {
let content = std::fs::read_to_string(path).map_err(|e| Error::RuleFileLoad {
path: path.to_path_buf(),
source: e,
})?;
self.parse_with_location(&content, Some(path))
}
pub fn parse_glob(&mut self, pattern: &str) -> Result<()> {
let paths = glob::glob(pattern)
.map_err(|e| Error::parse(format!("invalid glob pattern: {}", e), pattern))?;
for entry in paths {
match entry {
Ok(path) => {
if path.is_file() {
self.parse_file(&path)?;
}
}
Err(e) => {
tracing::warn!(error = %e, "error reading glob entry");
}
}
}
Ok(())
}
pub fn into_directives(self) -> Vec<Directive> {
self.directives
}
pub fn directives(&self) -> &[Directive] {
&self.directives
}
fn parse_directive(&mut self, name: &str, lexer: &mut Lexer) -> Result<Directive> {
match name.to_lowercase().as_str() {
"secrule" => self.parse_secrule(lexer),
"secaction" => self.parse_secaction(lexer),
"secmarker" => self.parse_secmarker(lexer),
"secruleengine" => self.parse_secruleengine(lexer),
"secdefaultaction" => self.parse_secdefaultaction(lexer),
"secruleremovebyid" => self.parse_secruleremovebyid(lexer),
"secruleupdatetargetbyid" => self.parse_secruleupdatetargetbyid(lexer),
"secrequestbodyaccess" => self.parse_boolean_directive(lexer, "SecRequestBodyAccess"),
"secresponsebodyaccess" => self.parse_boolean_directive(lexer, "SecResponseBodyAccess"),
"include" => self.parse_include(lexer),
"seccomponentsignature" | "seccollectiontimeout" => {
self.skip_to_end_of_line(lexer);
Ok(Directive::Unknown(name.to_string()))
}
_ => {
tracing::warn!(
directive = name,
location = %self.location,
"unknown directive, skipping"
);
self.skip_to_end_of_line(lexer);
Ok(Directive::Unknown(name.to_string()))
}
}
}
fn parse_secrule(&mut self, lexer: &mut Lexer) -> Result<Directive> {
let variables_str = self.expect_argument(lexer, "SecRule variables")?;
let variables = variable::parse_variables(&variables_str)?;
let operator_str = self.expect_quoted_argument(lexer, "SecRule operator")?;
let mut operator = operator::parse_operator(&operator_str)?;
self.resolve_operator_file_path(&mut operator);
let actions = if self.peek_quoted(lexer) {
let actions_str = self.expect_quoted_argument(lexer, "SecRule actions")?;
let mut actions = action::parse_actions(&actions_str)?;
actions = self.merge_default_actions(actions);
actions
} else {
self.defaults_for_phase(DEFAULT_PHASE).to_vec()
};
Ok(Directive::SecRule(SecRule {
variables,
operator,
actions,
location: self.location.clone(),
}))
}
fn resolve_operator_file_path(&self, operator: &mut OperatorSpec) {
if !matches!(
operator.name,
OperatorName::PmFromFile | OperatorName::IpMatchFromFile
) {
return;
}
if std::path::Path::new(&operator.argument).is_absolute() {
return;
}
if let Some(parent) = self.location.file.as_ref().and_then(|f| f.parent()) {
let joined = parent.join(&operator.argument);
if joined.exists() {
operator.argument = joined.to_string_lossy().into_owned();
}
}
}
fn parse_secaction(&mut self, lexer: &mut Lexer) -> Result<Directive> {
let actions_str = self.expect_quoted_argument(lexer, "SecAction")?;
let actions = action::parse_actions(&actions_str)?;
Ok(Directive::SecAction(SecAction {
actions,
location: self.location.clone(),
}))
}
fn parse_secmarker(&mut self, lexer: &mut Lexer) -> Result<Directive> {
let name = self.expect_argument(lexer, "SecMarker name")?;
Ok(Directive::SecMarker(SecMarker { name }))
}
fn parse_secruleengine(&mut self, lexer: &mut Lexer) -> Result<Directive> {
let mode_str = self.expect_argument(lexer, "SecRuleEngine mode")?;
let mode = match mode_str.to_lowercase().as_str() {
"on" => RuleEngineMode::On,
"off" => RuleEngineMode::Off,
"detectiononly" => RuleEngineMode::DetectionOnly,
_ => {
return Err(Error::parse(
format!("invalid SecRuleEngine mode: {}", mode_str),
self.location.to_string(),
));
}
};
Ok(Directive::SecRuleEngine(mode))
}
fn parse_secdefaultaction(&mut self, lexer: &mut Lexer) -> Result<Directive> {
let actions_str = self.expect_quoted_argument(lexer, "SecDefaultAction")?;
let actions = action::parse_actions(&actions_str)?;
self.default_actions
.insert(phase_of(&actions).unwrap_or(DEFAULT_PHASE), actions.clone());
Ok(Directive::SecDefaultAction(actions))
}
fn parse_secruleremovebyid(&mut self, lexer: &mut Lexer) -> Result<Directive> {
let mut ids = Vec::new();
loop {
let arg = self.expect_argument(lexer, "SecRuleRemoveById")?;
ids.extend(self.parse_id_selectors(&arg, "SecRuleRemoveById")?);
if !self.peek_more_arguments(lexer) {
break;
}
}
Ok(Directive::SecRuleRemoveById(ids))
}
fn parse_secruleupdatetargetbyid(&mut self, lexer: &mut Lexer) -> Result<Directive> {
let ids_str = self.expect_argument(lexer, "SecRuleUpdateTargetById id")?;
let ids = self.parse_id_selectors(&ids_str, "SecRuleUpdateTargetById")?;
let targets_str = self.expect_argument(lexer, "SecRuleUpdateTargetById targets")?;
let (additions, exclusions) = variable::parse_update_targets(&targets_str)?;
if additions.is_empty() && exclusions.is_empty() {
return Err(Error::parse(
"SecRuleUpdateTargetById requires at least one target",
self.location.to_string(),
));
}
let replaced = if self.peek_more_arguments(lexer) {
Some(self.expect_argument(lexer, "SecRuleUpdateTargetById replaced target")?)
} else {
None
};
Ok(Directive::SecRuleUpdateTargetById(UpdateTargetById {
ids,
additions,
exclusions,
replaced,
location: self.location.clone(),
}))
}
fn parse_id_selectors(&self, input: &str, context: &str) -> Result<Vec<RuleIdSelector>> {
let mut selectors = Vec::new();
for token in input.split_whitespace() {
let selector = if let Some((start, end)) = token.split_once('-') {
let start: u64 = start.trim().parse().map_err(|_| {
Error::parse(
format!("{context}: invalid rule id range '{token}'"),
self.location.to_string(),
)
})?;
let end: u64 = end.trim().parse().map_err(|_| {
Error::parse(
format!("{context}: invalid rule id range '{token}'"),
self.location.to_string(),
)
})?;
if start > end {
return Err(Error::parse(
format!("{context}: invalid rule id range '{token}' (start > end)"),
self.location.to_string(),
));
}
RuleIdSelector::Range(start, end)
} else {
RuleIdSelector::Single(token.parse().map_err(|_| {
Error::parse(
format!("{context}: invalid rule id '{token}'"),
self.location.to_string(),
)
})?)
};
selectors.push(selector);
}
if selectors.is_empty() {
return Err(Error::parse(
format!("{context}: expected at least one rule id"),
self.location.to_string(),
));
}
Ok(selectors)
}
fn peek_more_arguments(&self, lexer: &mut Lexer) -> bool {
lexer.skip_whitespace();
!matches!(lexer.peek(), None | Some('\n') | Some('\r') | Some('#'))
}
fn parse_boolean_directive(&mut self, lexer: &mut Lexer, name: &str) -> Result<Directive> {
let value_str = self.expect_argument(lexer, name)?;
let value = match value_str.to_lowercase().as_str() {
"on" => true,
"off" => false,
_ => {
return Err(Error::parse(
format!("invalid {} value: {} (expected On/Off)", name, value_str),
self.location.to_string(),
));
}
};
match name {
"SecRequestBodyAccess" => Ok(Directive::SecRequestBodyAccess(value)),
"SecResponseBodyAccess" => Ok(Directive::SecResponseBodyAccess(value)),
_ => Ok(Directive::Unknown(name.to_string())),
}
}
fn parse_include(&mut self, lexer: &mut Lexer) -> Result<Directive> {
let path = self.expect_argument(lexer, "Include path")?;
let resolved_path = if let Some(parent) = self.location.file.as_ref().and_then(|f| f.parent())
{
let candidate = parent.join(&path);
let is_glob = path.contains(['*', '?', '[']);
if candidate.exists() || is_glob {
candidate.to_string_lossy().to_string()
} else {
path
}
} else {
path
};
self.parse_glob(&resolved_path)?;
Ok(Directive::Include(resolved_path.into()))
}
fn expect_argument(&mut self, lexer: &mut Lexer, context: &str) -> Result<String> {
lexer.skip_whitespace();
match lexer.next_token() {
Some(token) => match token.kind {
TokenKind::Word(s) | TokenKind::QuotedString(s) => Ok(s),
_ => Err(Error::parse(
format!("expected {} but got {:?}", context, token.kind),
self.location.to_string(),
)),
},
None => Err(Error::parse(
format!("expected {} but got end of input", context),
self.location.to_string(),
)),
}
}
fn expect_quoted_argument(&mut self, lexer: &mut Lexer, context: &str) -> Result<String> {
lexer.skip_whitespace();
match lexer.next_token() {
Some(token) => match token.kind {
TokenKind::QuotedString(s) => Ok(s),
_ => Err(Error::parse(
format!("expected quoted {} but got {:?}", context, token.kind),
self.location.to_string(),
)),
},
None => Err(Error::parse(
format!("expected quoted {} but got end of input", context),
self.location.to_string(),
)),
}
}
fn peek_quoted(&self, lexer: &mut Lexer) -> bool {
lexer.skip_whitespace();
lexer.peek().map(|c| c == '"' || c == '\'').unwrap_or(false)
}
fn skip_to_end_of_line(&self, lexer: &mut Lexer) {
while let Some(token) = lexer.next_token() {
if matches!(token.kind, TokenKind::Newline) {
break;
}
}
}
fn merge_default_actions(&self, rule_actions: Vec<Action>) -> Vec<Action> {
let phase = phase_of(&rule_actions).unwrap_or(DEFAULT_PHASE);
let defaults = self.defaults_for_phase(phase);
let inherited_disruptive = defaults
.iter()
.find(|a| matches!(a, Action::Disruptive(_)))
.cloned();
let mut result = defaults.to_vec();
for action in rule_actions {
result.retain(|a| !actions_same_type(a, &action));
result.push(action);
}
if let Some(pos) = result
.iter()
.position(|a| matches!(a, Action::Disruptive(DisruptiveAction::Block)))
{
match inherited_disruptive {
Some(Action::Disruptive(DisruptiveAction::Block)) | None => {}
Some(inherited) => result[pos] = inherited,
}
}
result
}
fn defaults_for_phase(&self, phase: u8) -> &[Action] {
self.default_actions
.get(&phase)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
}
const DEFAULT_PHASE: u8 = 2;
fn phase_of(actions: &[Action]) -> Option<u8> {
actions.iter().find_map(|a| match a {
Action::Metadata(MetadataAction::Phase(p)) => Some(*p),
_ => None,
})
}
impl Default for Parser {
fn default() -> Self {
Self::new()
}
}
fn actions_same_type(a: &Action, b: &Action) -> bool {
match (a, b) {
(Action::Metadata(ma), Action::Metadata(mb)) => {
std::mem::discriminant(ma) == std::mem::discriminant(mb)
}
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_rule() {
let mut parser = Parser::new();
parser
.parse(r#"SecRule REQUEST_URI "@contains /admin" "id:1,deny,status:403""#)
.unwrap();
assert_eq!(parser.directives.len(), 1);
match &parser.directives[0] {
Directive::SecRule(rule) => {
assert_eq!(rule.variables.len(), 1);
assert_eq!(rule.variables[0].name, VariableName::RequestUri);
}
_ => panic!("expected SecRule"),
}
}
#[test]
fn test_parse_secruleengine() {
let mut parser = Parser::new();
parser.parse("SecRuleEngine On").unwrap();
assert_eq!(parser.directives.len(), 1);
match &parser.directives[0] {
Directive::SecRuleEngine(mode) => {
assert_eq!(*mode, RuleEngineMode::On);
}
_ => panic!("expected SecRuleEngine"),
}
}
#[test]
fn test_relative_include_glob_resolves() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("rules");
std::fs::create_dir(&sub).unwrap();
std::fs::write(
sub.join("a.conf"),
r#"SecRule REQUEST_URI "@contains /a" "id:101,phase:1,deny""#,
)
.unwrap();
std::fs::write(
sub.join("b.conf"),
r#"SecRule REQUEST_URI "@contains /b" "id:102,phase:1,deny""#,
)
.unwrap();
let entry = dir.path().join("entry.conf");
std::fs::write(&entry, "Include rules/*.conf\n").unwrap();
let mut parser = Parser::new();
parser.parse_file(&entry).unwrap();
let secrules = parser
.directives
.iter()
.filter(|d| matches!(d, Directive::SecRule(_)))
.count();
assert_eq!(secrules, 2, "both included rule files should be parsed");
}
#[test]
fn test_pmfromfile_relative_path_resolves() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("patterns.data"), "evilword\n").unwrap();
let conf = dir.path().join("rules.conf");
std::fs::write(
&conf,
r#"SecRule ARGS "@pmFromFile patterns.data" "id:201,phase:1,deny""#,
)
.unwrap();
let mut parser = Parser::new();
parser.parse_file(&conf).unwrap();
match &parser.directives[0] {
Directive::SecRule(rule) => {
let arg = &rule.operator.argument;
let p = std::path::Path::new(arg);
assert!(p.is_absolute(), "data path should be resolved to absolute: {arg}");
assert!(p.exists(), "resolved data path should exist: {arg}");
}
_ => panic!("expected SecRule"),
}
}
#[test]
fn test_normalise_path_transformation_alias() {
let rs = crate::engine::CompiledRuleset::from_string(
r#"SecRule REQUEST_URI "@contains /etc" "id:301,phase:1,t:normalisePath,deny""#,
);
assert!(rs.is_ok(), "normalisePath must be recognized: {:?}", rs.err());
}
}