Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 48 additions & 22 deletions crates/parapet/src/collections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,39 @@
use std::borrow::Cow;

use crate::macros::MacroContext;
use crate::rule::{Collection, Selector, Target};
use crate::rule::Collection;

/// A target whose member-selector is compiled, ready to resolve without any
/// per-request work.
///
/// The parsed [`crate::rule::Target`] keeps its selector as a string so the
/// rule set stays serialisable; compilation turns it into this, where a regex
/// selector is a compiled automaton rather than a pattern to rebuild on every
/// resolution. A rule set with many regex selectors resolved thousands of
/// values per request, and recompiling the selector for each one measured as
/// the dominant cost of inspection.
#[derive(Debug, Clone)]
pub struct CompiledTarget {
/// The collection to read.
pub collection: Collection,
/// Which members to keep, if narrowed.
pub selector: Option<CompiledSelector>,
/// A leading `!`: remove these members from the result.
pub exclusion: bool,
/// A leading `&`: inspect the member count, not the values.
pub count: bool,
}

/// A member-selector, compiled.
#[derive(Debug, Clone)]
pub enum CompiledSelector {
/// `ARGS:username`, an exact member name.
Name(String),
/// `REQUEST_HEADERS:/^X-/`, compiled once.
Regex(regex::Regex),
/// `XML:/*`, an XPath expression, kept as authored.
XPath(String),
}

/// One resolved member of a collection.
///
Expand Down Expand Up @@ -193,9 +225,9 @@ impl Variables {
///
/// Exclusions (`!ARGS:x`) are applied after collection, so order within
/// the target list does not matter, matching SecLang.
pub fn resolve(&self, targets: &[Target]) -> Vec<Value<'_>> {
pub fn resolve(&self, targets: &[CompiledTarget]) -> Vec<Value<'_>> {
let mut out: Vec<Value<'_>> = Vec::new();
let mut excluded: Vec<(Collection, Option<&Selector>)> = Vec::new();
let mut excluded: Vec<(Collection, Option<&CompiledSelector>)> = Vec::new();

for target in targets {
if target.exclusion {
Expand All @@ -214,31 +246,27 @@ impl Variables {
}
match selector {
None => true,
Some(Selector::Name(n)) => value
Some(CompiledSelector::Name(n)) => value
.name
.strip_prefix(prefix)
.and_then(|r| r.strip_prefix(':'))
.is_some_and(|member| member.eq_ignore_ascii_case(n)),
Some(Selector::Regex(pattern)) => value
Some(CompiledSelector::Regex(re)) => value
.name
.strip_prefix(prefix)
.and_then(|r| r.strip_prefix(':'))
.is_some_and(|member| {
regex::Regex::new(pattern)
.map(|re| re.is_match(member))
.unwrap_or(false)
}),
.is_some_and(|member| re.is_match(member)),
// An XPath exclusion cannot be evaluated without an
// XML tree, and XML is never populated yet.
Some(Selector::XPath(_)) => false,
Some(CompiledSelector::XPath(_)) => false,
}
})
});
}
out
}

fn resolve_one<'a>(&'a self, target: &Target, out: &mut Vec<Value<'a>>) {
fn resolve_one<'a>(&'a self, target: &CompiledTarget, out: &mut Vec<Value<'a>>) {
use Collection::*;
let prefix = collection_name(target.collection);

Expand Down Expand Up @@ -308,10 +336,10 @@ impl Variables {
// else is refused at compile time, so reaching here with another
// form is impossible rather than silently empty.
Xml => match target.selector.as_ref() {
Some(Selector::XPath(expr)) if expr.trim() == "/*" => {
Some(CompiledSelector::XPath(expr)) if expr.trim() == "/*" => {
push_map(out, prefix, &self.xml_elements, None)
}
Some(Selector::XPath(expr)) if expr.trim() == "//@*" => {
Some(CompiledSelector::XPath(expr)) if expr.trim() == "//@*" => {
push_map(out, prefix, &self.xml_attributes, None)
}
_ => {}
Expand All @@ -331,10 +359,10 @@ impl Variables {
/// that one header is present, not how many headers there are. Counting
/// the whole collection instead makes every presence check true, which
/// silently fires the rules that test for a header being absent.
fn count_of(&self, target: &Target) -> usize {
fn count_of(&self, target: &CompiledTarget) -> usize {
let mut resolved = Vec::new();
self.resolve_one(
&Target {
&CompiledTarget {
collection: target.collection,
selector: target.selector.clone(),
exclusion: false,
Expand Down Expand Up @@ -421,16 +449,14 @@ fn push_map<'a>(
out: &mut Vec<Value<'a>>,
prefix: &'a str,
map: &'a Multimap,
selector: Option<&Selector>,
selector: Option<&CompiledSelector>,
) {
for (name, value) in map.iter() {
let keep = match selector {
None => true,
Some(Selector::Name(want)) => name.eq_ignore_ascii_case(want),
Some(Selector::Regex(pattern)) => regex::Regex::new(pattern)
.map(|re| re.is_match(name))
.unwrap_or(false),
Some(Selector::XPath(_)) => false,
Some(CompiledSelector::Name(want)) => name.eq_ignore_ascii_case(want),
Some(CompiledSelector::Regex(re)) => re.is_match(name),
Some(CompiledSelector::XPath(_)) => false,
};
if keep {
out.push(Value {
Expand Down
90 changes: 52 additions & 38 deletions crates/parapet/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use std::collections::HashMap;

use crate::action::{Action, Ctl, SetVar, SetVarOp, Transformation};
use crate::collections::{CompiledSelector, CompiledTarget};
use crate::macros::Template;
use crate::matcher::{CompiledOperator, DataLoader, OperatorCompileError};
use crate::rule::{Collection, Directive, Rule, Selector, Severity, Target};
Expand Down Expand Up @@ -39,7 +40,7 @@ pub struct SetVarSpec {
#[derive(Debug)]
pub struct ChainLink {
/// Variables this link inspects.
pub targets: Vec<Target>,
pub targets: Vec<CompiledTarget>,
/// The link's test.
pub operator: Option<CompiledOperator>,
/// Whether the link's result is inverted.
Expand All @@ -60,7 +61,7 @@ pub struct CompiledRule {
/// Which phase the rule runs in.
pub phase: Phase,
/// Variables the rule inspects. Empty for `SecAction`.
pub targets: Vec<Target>,
pub targets: Vec<CompiledTarget>,
/// The rule's test. `None` means always match, as `SecAction` does.
pub operator: Option<CompiledOperator>,
/// Whether the operator result is inverted.
Expand Down Expand Up @@ -365,14 +366,14 @@ fn compile_rule(
) -> Result<CompiledRule, CompileError> {
let id = starter.id();
let line = starter.line;
check_targets(&starter.targets, id, line)?;
let targets = compile_targets(&starter.targets, id, line)?;
let operator = compile_operator(starter, loader)?;

let mut compiled = CompiledRule {
id,
// SecLang defaults a rule with no explicit phase to phase 2.
phase: Phase::RequestBody,
targets: starter.targets.clone(),
targets,
operator,
negated: starter.negated,
transformations: Vec::new(),
Expand All @@ -397,13 +398,13 @@ fn compile_rule(
}

for link in links {
check_targets(
let link_targets = compile_targets(
&link.targets,
link.id().unwrap_or(id.unwrap_or(0)),
link.line,
)?;
let mut chain_link = ChainLink {
targets: link.targets.clone(),
targets: link_targets,
operator: compile_operator(link, loader)?,
negated: link.negated,
transformations: Vec::new(),
Expand All @@ -426,44 +427,57 @@ fn compile_rule(
Ok(compiled)
}

/// Reject targets Parapet cannot resolve, before they become silent no-ops.
/// Compile a target list into its evaluable form.
///
/// A selector that cannot be evaluated selects nothing, and a rule that
/// inspects nothing cannot fire. Both an unsupported XPath and an uncompilable
/// regex selector are refused here so that failure surfaces at compile time
/// rather than as a silent bypass at request time.
fn check_targets(
/// A regex selector is compiled once here rather than on every resolution, and
/// a selector that cannot be evaluated is refused rather than left to select
/// nothing at request time: a rule that inspects nothing cannot fire, and it
/// would do so silently. An unsupported XPath and an uncompilable regex
/// selector both surface here, at compile time.
fn compile_targets(
targets: &[Target],
id: impl Into<Option<u32>>,
line: usize,
) -> Result<(), CompileError> {
) -> Result<Vec<CompiledTarget>, CompileError> {
let id = id.into();
for target in targets {
match &target.selector {
Some(Selector::XPath(expression)) if target.collection == Collection::Xml => {
if !crate::xml::xpath_is_supported(expression) {
return Err(CompileError::UnsupportedXPath {
id: id.unwrap_or(0),
line,
expression: expression.clone(),
supported: crate::xml::SUPPORTED_XPATH,
});
targets
.iter()
.map(|target| {
let selector = match &target.selector {
None => None,
Some(Selector::Name(name)) => Some(CompiledSelector::Name(name.clone())),
Some(Selector::XPath(expression)) => {
if target.collection == Collection::Xml
&& !crate::xml::xpath_is_supported(expression)
{
return Err(CompileError::UnsupportedXPath {
id: id.unwrap_or(0),
line,
expression: expression.clone(),
supported: crate::xml::SUPPORTED_XPATH,
});
}
Some(CompiledSelector::XPath(expression.clone()))
}
}
// The same engine compiles this at resolve time. Validating it here
// with the same constructor guarantees that never fails silently.
Some(Selector::Regex(pattern)) => {
regex::Regex::new(pattern).map_err(|e| CompileError::InvalidSelector {
id: id.unwrap_or(0),
line,
selector: pattern.clone(),
error: e.to_string(),
})?;
}
_ => {}
}
}
Ok(())
Some(Selector::Regex(pattern)) => {
let re =
regex::Regex::new(pattern).map_err(|e| CompileError::InvalidSelector {
id: id.unwrap_or(0),
line,
selector: pattern.clone(),
error: e.to_string(),
})?;
Some(CompiledSelector::Regex(re))
}
};
Ok(CompiledTarget {
collection: target.collection,
selector,
exclusion: target.exclusion,
count: target.count,
})
})
.collect()
}

fn compile_operator(
Expand Down
5 changes: 2 additions & 3 deletions crates/parapet/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
//! first disruptive action, as SecLang specifies.

use crate::action::{Ctl, RuleEngineMode, SetVarOp, Transformation};
use crate::collections::{BodyError, OwnedValue, Variables};
use crate::collections::{BodyError, CompiledTarget, OwnedValue, Variables};
use crate::engine::{ChainLink, CompiledRule, Disruptive, RuleSet, SetVarSpec};
use crate::matcher::CompiledOperator;
use crate::rule::Target;
use crate::{Phase, Verdict};

/// Whether the engine blocks or only records.
Expand Down Expand Up @@ -68,7 +67,7 @@ pub struct Transaction<'r> {
/// One operator evaluation: what to inspect, how to prepare it, and what to
/// test it with.
struct Step<'a> {
targets: &'a [Target],
targets: &'a [CompiledTarget],
operator: &'a CompiledOperator,
negated: bool,
transformations: &'a [Transformation],
Expand Down
Loading