use std::collections::HashSet;
use crate::parser::{ControlAction, Selection, VariableSpec};
use super::ruleset::RuleEngineMode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleIdRange {
start: u64,
end: u64,
}
impl RuleIdRange {
fn contains(&self, id: &str) -> bool {
match id.trim().parse::<u64>() {
Ok(n) => n >= self.start && n <= self.end,
Err(_) => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CtlDirective {
RuleEngine(RuleEngineMode),
RuleRemoveById(Vec<RuleIdRange>),
RuleRemoveTargetById {
rule_id: String,
target: String,
},
RequestBodyAccess(bool),
RequestBodyProcessor(String),
Unsupported {
directive: String,
value: String,
reason: &'static str,
},
}
const SUPPORTED_BODY_PROCESSORS: &[&str] = &["URLENCODED", "MULTIPART", "JSON"];
impl CtlDirective {
pub fn parse(action: &ControlAction) -> Self {
let directive = action.directive.trim();
let value = action.value.trim();
match directive.to_ascii_lowercase().as_str() {
"ruleengine" => match value.to_ascii_lowercase().as_str() {
"on" => CtlDirective::RuleEngine(RuleEngineMode::On),
"off" => CtlDirective::RuleEngine(RuleEngineMode::Off),
"detectiononly" => CtlDirective::RuleEngine(RuleEngineMode::DetectionOnly),
_ => CtlDirective::unsupported(
directive,
value,
"ruleEngine accepts only On, Off or DetectionOnly",
),
},
"ruleremovebyid" => match parse_id_ranges(value) {
Some(ranges) if !ranges.is_empty() => CtlDirective::RuleRemoveById(ranges),
_ => CtlDirective::unsupported(
directive,
value,
"ruleRemoveById expects a rule ID or an ID range such as 942100-942999",
),
},
"ruleremovetargetbyid" => match value.split_once(';') {
Some((rule_id, target))
if !rule_id.trim().is_empty() && !target.trim().is_empty() =>
{
CtlDirective::RuleRemoveTargetById {
rule_id: rule_id.trim().to_string(),
target: target.trim().to_string(),
}
}
_ => CtlDirective::unsupported(
directive,
value,
"ruleRemoveTargetById expects ID;TARGET, for example 942100;ARGS:token",
),
},
"requestbodyaccess" => match value.to_ascii_lowercase().as_str() {
"on" => CtlDirective::RequestBodyAccess(true),
"off" => CtlDirective::RequestBodyAccess(false),
_ => CtlDirective::unsupported(
directive,
value,
"requestBodyAccess accepts only On or Off",
),
},
"requestbodyprocessor" => {
let upper = value.to_ascii_uppercase();
if SUPPORTED_BODY_PROCESSORS.contains(&upper.as_str()) {
CtlDirective::RequestBodyProcessor(upper)
} else {
CtlDirective::unsupported(
directive,
value,
"this engine implements the URLENCODED, MULTIPART and JSON body \
processors; XML bodies would go unexamined",
)
}
}
"auditengine" | "auditlogparts" => CtlDirective::unsupported(
directive,
value,
"this engine has no audit log subsystem, so there is nothing to control",
),
_ => CtlDirective::unsupported(directive, value, "unrecognised ctl directive"),
}
}
fn unsupported(directive: &str, value: &str, reason: &'static str) -> Self {
CtlDirective::Unsupported {
directive: directive.to_string(),
value: value.to_string(),
reason,
}
}
}
fn parse_id_ranges(value: &str) -> Option<Vec<RuleIdRange>> {
let mut ranges = Vec::new();
for part in value.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let range = match part.split_once('-') {
Some((start, end)) => {
let start: u64 = start.trim().parse().ok()?;
let end: u64 = end.trim().parse().ok()?;
if start > end {
return None;
}
RuleIdRange { start, end }
}
None => {
let id: u64 = part.parse().ok()?;
RuleIdRange { start: id, end: id }
}
};
ranges.push(range);
}
Some(ranges)
}
#[derive(Debug, Default, Clone)]
pub struct TransactionControls {
engine_mode: Option<RuleEngineMode>,
removed_rules: Vec<RuleIdRange>,
removed_targets: Vec<(String, VariableSpec)>,
request_body_access: Option<bool>,
request_body_processor: Option<String>,
reported: HashSet<String>,
}
impl TransactionControls {
pub fn apply(&mut self, directive: CtlDirective) {
match directive {
CtlDirective::RuleEngine(mode) => self.engine_mode = Some(mode),
CtlDirective::RuleRemoveById(mut ranges) => self.removed_rules.append(&mut ranges),
CtlDirective::RuleRemoveTargetById { rule_id, target } => {
match crate::parser::parse_single_variable(&target) {
Ok(spec) => self.removed_targets.push((rule_id, spec)),
Err(_) => {
let key = format!("ruleRemoveTargetById={rule_id};{target}");
if self.reported.insert(key) {
tracing::warn!(
rule_id = %rule_id,
target = %target,
"ignoring ctl:ruleRemoveTargetById with an unparseable target"
);
}
}
}
}
CtlDirective::RequestBodyAccess(on) => self.request_body_access = Some(on),
CtlDirective::RequestBodyProcessor(p) => self.request_body_processor = Some(p),
CtlDirective::Unsupported {
directive,
value,
reason,
} => {
let key = format!("{directive}={value}");
if self.reported.insert(key) {
tracing::warn!(
directive = %directive,
value = %value,
reason = %reason,
"ignoring unsupported ctl directive"
);
}
}
}
}
pub fn engine_mode(&self, ruleset_mode: RuleEngineMode) -> RuleEngineMode {
self.engine_mode.unwrap_or(ruleset_mode)
}
pub fn is_rule_removed(&self, rule_id: Option<&str>) -> bool {
let Some(id) = rule_id else {
return false;
};
self.removed_rules.iter().any(|r| r.contains(id))
}
pub fn has_target_removals(&self, rule_id: Option<&str>) -> bool {
match rule_id {
Some(id) => self.removed_targets.iter().any(|(r, _)| r == id),
None => false,
}
}
pub fn is_target_removed(
&self,
rule_id: Option<&str>,
var: &VariableSpec,
resolved_name: &str,
) -> bool {
let Some(id) = rule_id else {
return false;
};
self.removed_targets
.iter()
.any(|(r, target)| r == id && target_matches_resolved(var, target, resolved_name))
}
pub fn request_body_access(&self) -> bool {
self.request_body_access.unwrap_or(true)
}
pub fn request_body_processor(&self) -> Option<&str> {
self.request_body_processor.as_deref()
}
}
fn target_matches_resolved(var: &VariableSpec, target: &VariableSpec, resolved_name: &str) -> bool {
if var.name != target.name {
return false;
}
match &target.selection {
None => true,
Some(Selection::Key(wanted)) => {
let Some((_, key)) = resolved_name.split_once(':') else {
return false;
};
if header_keys_are_case_insensitive(var) {
key.eq_ignore_ascii_case(wanted.as_str())
} else {
key == wanted.as_str()
}
}
Some(Selection::Regex(_)) => false,
}
}
fn header_keys_are_case_insensitive(var: &VariableSpec) -> bool {
matches!(
var.name,
crate::parser::VariableName::RequestHeaders | crate::parser::VariableName::ResponseHeaders
)
}
pub fn unsupported_controls(
actions: &[crate::parser::Action],
) -> Vec<(String, String, &'static str)> {
let mut found = Vec::new();
for action in actions {
if let crate::parser::Action::Control(ctl) = action {
if let CtlDirective::Unsupported {
directive,
value,
reason,
} = CtlDirective::parse(ctl)
{
found.push((directive, value, reason));
}
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
fn ctl(directive: &str, value: &str) -> ControlAction {
ControlAction {
directive: directive.to_string(),
value: value.to_string(),
}
}
#[test]
fn rule_engine_values_are_case_insensitive() {
for (value, expected) in [
("Off", RuleEngineMode::Off),
("off", RuleEngineMode::Off),
("On", RuleEngineMode::On),
("DetectionOnly", RuleEngineMode::DetectionOnly),
("detectiononly", RuleEngineMode::DetectionOnly),
] {
assert_eq!(
CtlDirective::parse(&ctl("ruleEngine", value)),
CtlDirective::RuleEngine(expected),
"ruleEngine={value}"
);
}
}
#[test]
fn directive_names_are_case_insensitive() {
assert!(matches!(
CtlDirective::parse(&ctl("RULEREMOVEBYID", "1")),
CtlDirective::RuleRemoveById(_)
));
}
#[test]
fn single_ids_ranges_and_lists_all_parse() {
let single = CtlDirective::parse(&ctl("ruleRemoveById", "942100"));
assert_eq!(
single,
CtlDirective::RuleRemoveById(vec![RuleIdRange {
start: 942100,
end: 942100
}])
);
let range = CtlDirective::parse(&ctl("ruleRemoveById", "942100-942999"));
assert_eq!(
range,
CtlDirective::RuleRemoveById(vec![RuleIdRange {
start: 942100,
end: 942999
}])
);
let list = CtlDirective::parse(&ctl("ruleRemoveById", "1,5-7"));
assert_eq!(
list,
CtlDirective::RuleRemoveById(vec![
RuleIdRange { start: 1, end: 1 },
RuleIdRange { start: 5, end: 7 },
])
);
}
#[test]
fn an_inverted_range_is_rejected_rather_than_silently_empty() {
assert!(matches!(
CtlDirective::parse(&ctl("ruleRemoveById", "999-1")),
CtlDirective::Unsupported { .. }
));
}
#[test]
fn ranges_match_ids_by_number_not_string() {
let mut controls = TransactionControls::default();
controls.apply(CtlDirective::parse(&ctl("ruleRemoveById", "942100-942999")));
assert!(controls.is_rule_removed(Some("942100")));
assert!(controls.is_rule_removed(Some("942500")));
assert!(controls.is_rule_removed(Some("942999")));
assert!(!controls.is_rule_removed(Some("943000")));
assert!(!controls.is_rule_removed(Some("9421000")));
assert!(!controls.is_rule_removed(None));
}
#[test]
fn removals_accumulate_but_engine_mode_is_replaced() {
let mut controls = TransactionControls::default();
controls.apply(CtlDirective::parse(&ctl("ruleRemoveById", "1")));
controls.apply(CtlDirective::parse(&ctl("ruleRemoveById", "2")));
assert!(controls.is_rule_removed(Some("1")));
assert!(controls.is_rule_removed(Some("2")));
controls.apply(CtlDirective::parse(&ctl("ruleEngine", "Off")));
controls.apply(CtlDirective::parse(&ctl("ruleEngine", "DetectionOnly")));
assert_eq!(
controls.engine_mode(RuleEngineMode::On),
RuleEngineMode::DetectionOnly
);
}
#[test]
fn engine_mode_defers_to_the_ruleset_when_unset() {
let controls = TransactionControls::default();
assert_eq!(
controls.engine_mode(RuleEngineMode::DetectionOnly),
RuleEngineMode::DetectionOnly
);
}
#[test]
fn remove_target_requires_both_halves() {
assert_eq!(
CtlDirective::parse(&ctl("ruleRemoveTargetById", "942100;ARGS:token")),
CtlDirective::RuleRemoveTargetById {
rule_id: "942100".to_string(),
target: "ARGS:token".to_string(),
}
);
for bad in ["942100", "942100;", ";ARGS:token", ""] {
assert!(
matches!(
CtlDirective::parse(&ctl("ruleRemoveTargetById", bad)),
CtlDirective::Unsupported { .. }
),
"{bad:?} should not parse"
);
}
}
#[test]
fn target_matching_uses_seclang_names() {
let mut controls = TransactionControls::default();
controls.apply(CtlDirective::parse(&ctl(
"ruleRemoveTargetById",
"1;REQUEST_HEADERS:User-Agent",
)));
let var = crate::parser::parse_single_variable("REQUEST_HEADERS:User-Agent").unwrap();
assert!(controls.is_target_removed(Some("1"), &var, "REQUEST_HEADERS:User-Agent"));
assert!(!controls.is_target_removed(Some("2"), &var, "REQUEST_HEADERS:User-Agent"));
let other = crate::parser::parse_single_variable("REQUEST_HEADERS:Referer").unwrap();
assert!(!controls.is_target_removed(Some("1"), &other, "REQUEST_HEADERS:Referer"));
assert!(controls.is_target_removed(Some("1"), &var, "REQUEST_HEADERS:user-agent"));
}
#[test]
fn a_keyed_target_excludes_one_member_of_a_collection_rule() {
let mut controls = TransactionControls::default();
controls.apply(CtlDirective::parse(&ctl(
"ruleRemoveTargetById",
"942100;ARGS:json.token",
)));
let whole_args = crate::parser::parse_single_variable("ARGS").unwrap();
assert!(controls.is_target_removed(Some("942100"), &whole_args, "ARGS:json.token"));
assert!(!controls.is_target_removed(Some("942100"), &whole_args, "ARGS:json.query"));
}
#[test]
fn argument_keys_are_matched_case_sensitively() {
let mut controls = TransactionControls::default();
controls.apply(CtlDirective::parse(&ctl(
"ruleRemoveTargetById",
"1;ARGS:Token",
)));
let args = crate::parser::parse_single_variable("ARGS").unwrap();
assert!(controls.is_target_removed(Some("1"), &args, "ARGS:Token"));
assert!(!controls.is_target_removed(Some("1"), &args, "ARGS:token"));
}
#[test]
fn a_keyed_target_does_not_match_a_scalar_variable() {
let mut controls = TransactionControls::default();
controls.apply(CtlDirective::parse(&ctl(
"ruleRemoveTargetById",
"1;REQUEST_URI:x",
)));
let uri = crate::parser::parse_single_variable("REQUEST_URI").unwrap();
assert!(!controls.is_target_removed(Some("1"), &uri, "REQUEST_URI"));
}
#[test]
fn an_unkeyed_target_excludes_the_whole_collection() {
let mut controls = TransactionControls::default();
controls.apply(CtlDirective::parse(&ctl("ruleRemoveTargetById", "1;ARGS")));
let whole = crate::parser::parse_single_variable("ARGS").unwrap();
assert!(controls.is_target_removed(Some("1"), &whole, "ARGS:token"));
assert!(controls.is_target_removed(Some("1"), &whole, "ARGS:anything"));
let keyed = crate::parser::parse_single_variable("ARGS:token").unwrap();
assert!(controls.is_target_removed(Some("1"), &keyed, "ARGS:token"));
let cookies = crate::parser::parse_single_variable("REQUEST_COOKIES").unwrap();
assert!(!controls.is_target_removed(Some("1"), &cookies, "REQUEST_COOKIES:token"));
}
#[test]
fn unimplemented_features_are_reported_not_accepted() {
for (directive, value) in [
("requestBodyProcessor", "XML"),
("auditEngine", "Off"),
("auditLogParts", "+E"),
("forceRequestBodyVariable", "On"),
] {
assert!(
matches!(
CtlDirective::parse(&ctl(directive, value)),
CtlDirective::Unsupported { .. }
),
"ctl:{directive}={value} should be reported as unsupported"
);
}
}
#[test]
fn implemented_body_processors_are_accepted() {
for value in ["URLENCODED", "urlencoded", "MULTIPART", "JSON", "json"] {
assert!(
matches!(
CtlDirective::parse(&ctl("requestBodyProcessor", value)),
CtlDirective::RequestBodyProcessor(_)
),
"ctl:requestBodyProcessor={value} should be accepted"
);
}
}
#[test]
fn body_access_defaults_to_on() {
let mut controls = TransactionControls::default();
assert!(controls.request_body_access());
controls.apply(CtlDirective::parse(&ctl("requestBodyAccess", "Off")));
assert!(!controls.request_body_access());
}
}