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
52 changes: 25 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,35 +88,33 @@ after a 200-iteration warm-up:

| Request | p50 | p99 |
|---|---|---|
| GET, no query string | 311 µs | 341 µs |
| GET, one short parameter | 344 µs | 381 µs |
| GET, ten parameters | 591 µs | 631 µs |
| POST, small form body | 382 µs | 416 µs |
| POST, 4 KB JSON body | 734 µs | 785 µs |
| GET, SQLi (blocks) | 346 µs | 384 µs |

**Inside a real gateway**, the same rule set on the same machine measured
**1.9 ms mean** over 2,000 live requests, with 97% under 2.5 ms. That is 5 to 6
times the isolated figure, and the live number is the one to plan with. A tight
loop keeps the rule set hot in cache and the branch predictors trained; a
server interleaving HTTP work does not. Treat the table above as a floor rather
than as the cost.

At roughly 2 ms, one core sustains on the order of 500 requests per second of
inspection. That is capacity planning, not a footnote.
| GET, no query string | 52 µs | 64 µs |
| GET, one short parameter | 78 µs | 184 µs |
| GET, ten parameters | 268 µs | 296 µs |
| POST, small form body | 111 µs | 145 µs |
| POST, 4 KB JSON body | 463 µs | 539 µs |
| GET, nine browser cookies | 277 µs | 311 µs |
| GET, twenty-four cookies | 603 µs | 653 µs |
| GET, SQLi (blocks) | 83 µs | 96 µs |

Cost scales with the number of values inspected, which is why ten parameters
cost more than one. Cookies are a first-order cost of their own: CRS resolves
`REQUEST_COOKIES` on 162 rules, each carrying a `!REQUEST_COOKIES:/__utm/`
exclusion, so a request's cost rises with the size of its cookie jar.

A tight loop keeps the rule set hot in cache and the branch predictors trained;
a server interleaving HTTP work does not, so a real gateway runs somewhat above
these figures. Treat the table as a floor, and measure a deployment's own
in-gateway cost before planning capacity against it.

This is what running a full rule set costs, not overhead that can be tuned
away: it is 591 rules, each applying its transformations and evaluating its
operator against every target the rule names. Cost scales with the number of
inspected values, which is why ten parameters costs roughly twice what one
does.

Two things follow. Inspection dominates any per-request budget it shares, so
it belongs behind a decision about which routes need it rather than switched on
globally by reflex. And the effective lever is the size of the rule set: a
lower paranoia level, or scoping rules to the operations that can actually be
attacked in that way, changes this number far more than micro-optimisation
does. An earlier attempt to remove the per-rule value copies bought 7%.
away: 591 rules, each applying its transformations and evaluating its operator
against every target it names. The effective lever is the size of the rule set:
a lower paranoia level, or scoping rules to the operations that can actually be
attacked in a given way, changes this number far more than anything else. And
because inspection dominates any per-request budget it shares, it belongs behind
a decision about which routes need it rather than switched on globally by
reflex.

## Sealing a rule set into a host artifact

Expand Down
8 changes: 8 additions & 0 deletions crates/parapet-conformance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,11 @@ serde_json.workspace = true

[lints]
workspace = true

# Standalone per-request cost benchmark. Run against a CRS rules directory that
# includes crs-setup: see the file header. An example rather than a bin so the
# package keeps a single default binary and `cargo run -p parapet-conformance`
# stays unambiguous.
[[example]]
name = "bench_inspect"
path = "tools/bench_inspect.rs"
124 changes: 112 additions & 12 deletions crates/parapet-conformance/tools/bench_inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@
//! ```text
//! cp crs/rules/*.conf crs/rules/*.data bench-rules/
//! cp crs/crs-setup.conf.example bench-rules/000-crs-setup.conf
//! cargo run --release --bin bench_inspect -- bench-rules
//! cargo run --release -p parapet-conformance --example bench_inspect -- bench-rules
//! ```
// A standalone measurement tool: it reads a rules directory and panics on any
// malformed input rather than handling it, which is what you want from a
// benchmark harness.
#![allow(clippy::unwrap_used)]

use parapet::transaction::EngineMode;
use parapet::{RuleSet, Transaction, Verdict};
use std::time::Instant;
Expand All @@ -20,6 +25,10 @@ struct Case {
method: &'static str,
uri: &'static str,
body: Option<(&'static str, &'static str)>,
/// A `Cookie` header value. CRS resolves REQUEST_COOKIES with a
/// `!REQUEST_COOKIES:/__utm/` exclusion on 162 rules, so cookie count is a
/// first-order cost and a realistic browser request carries several.
cookies: Option<&'static str>,
}

fn main() {
Expand All @@ -43,24 +52,108 @@ fn main() {
let loader = parapet::DirDataLoader::new(&dir);
let start = Instant::now();
let (rules, errors) = RuleSet::compile_all(&directives, &loader);
println!("rules={} refused={} build={:.0}ms", rules.rule_count(), errors.len(), start.elapsed().as_secs_f64() * 1000.0);
println!(
"rules={} refused={} build={:.0}ms",
rules.rule_count(),
errors.len(),
start.elapsed().as_secs_f64() * 1000.0
);

// A typical browser cookie jar: session cookies plus the analytics cookies
// CRS explicitly excludes (__utm*, _pk_ref).
const BROWSER_COOKIES: &str = "sessionid=abc123def456; csrftoken=xyz789; __utma=1.2.3.4.5; __utmb=6.7.8.9; __utmc=10; __utmz=11.12.13.14.utmcsr=google; _pk_ref=%5B%22%22%2C%22%22%5D; theme=dark; lang=en";
// A heavier jar, to show how cost scales with cookie count.
let many = (0..24)
.map(|i| format!("c{i}=v{i}"))
.collect::<Vec<_>>()
.join("; ");
let many_cookies: &'static str = Box::leak(many.into_boxed_str());

let cases = [
Case { name: "benign GET, no query", method: "GET", uri: "/index.html", body: None },
Case { name: "benign GET, short query", method: "GET", uri: "/search?q=hello+world", body: None },
Case { name: "benign GET, 10 params", method: "GET", uri: "/s?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10", body: None },
Case { name: "benign POST, form body", method: "POST", uri: "/login", body: Some(("application/x-www-form-urlencoded", "user=alice&pass=hunter2")) },
Case { name: "benign POST, 4KB JSON", method: "POST", uri: "/api", body: Some(("application/json", "")) },
Case { name: "attack, sqli (blocks)", method: "GET", uri: "/s?q=1%20UNION%20SELECT%20p%20FROM%20u", body: None },
Case { name: "attack, xss (blocks)", method: "GET", uri: "/s?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E", body: None },
Case {
name: "benign GET, no query",
method: "GET",
uri: "/index.html",
body: None,
cookies: None,
},
Case {
name: "benign GET, short query",
method: "GET",
uri: "/search?q=hello+world",
body: None,
cookies: None,
},
Case {
name: "benign GET, 10 params",
method: "GET",
uri: "/s?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10",
body: None,
cookies: None,
},
Case {
name: "benign POST, form body",
method: "POST",
uri: "/login",
body: Some((
"application/x-www-form-urlencoded",
"user=alice&pass=hunter2",
)),
cookies: None,
},
Case {
name: "benign POST, 4KB JSON",
method: "POST",
uri: "/api",
body: Some(("application/json", "")),
cookies: None,
},
Case {
name: "benign GET, browser cookies",
method: "GET",
uri: "/index.html",
body: None,
cookies: Some(BROWSER_COOKIES),
},
Case {
name: "benign GET, 24 cookies",
method: "GET",
uri: "/index.html",
body: None,
cookies: Some(many_cookies),
},
Case {
name: "attack, sqli (blocks)",
method: "GET",
uri: "/s?q=1%20UNION%20SELECT%20p%20FROM%20u",
body: None,
cookies: None,
},
Case {
name: "attack, xss (blocks)",
method: "GET",
uri: "/s?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E",
body: None,
cookies: None,
},
];

let big_json = format!("{{\"data\":\"{}\"}}", "x".repeat(4000));

println!("\n{:<28} {:>9} {:>9} {:>9} {:>9} verdict", "case", "p50", "p90", "p99", "mean");
println!(
"\n{:<28} {:>9} {:>9} {:>9} {:>9} verdict",
"case", "p50", "p90", "p99", "mean"
);
for case in &cases {
let body_owned: Option<(&str, String)> = case.body.map(|(ct, b)| {
(ct, if b.is_empty() { big_json.clone() } else { b.to_string() })
(
ct,
if b.is_empty() {
big_json.clone()
} else {
b.to_string()
},
)
});

// Warm up so the first-touch page faults do not land in the sample.
Expand Down Expand Up @@ -94,7 +187,11 @@ fn main() {
pick(0.90) as f64 / 1000.0,
pick(0.99) as f64 / 1000.0,
mean / 1000.0,
if last == Verdict::Allow { "allow" } else { "block" }
if last == Verdict::Allow {
"allow"
} else {
"block"
}
);
}
}
Expand All @@ -110,6 +207,9 @@ fn run_one(rules: &RuleSet, case: &Case, body: Option<&(&str, String)>) -> Verdi
tx.add_request_header("Host", "example.test");
tx.add_request_header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64)");
tx.add_request_header("Accept", "text/html,application/xhtml+xml");
if let Some(cookies) = case.cookies {
tx.add_request_header("Cookie", cookies);
}
if let Some((ct, _)) = body {
tx.add_request_header("Content-Type", ct);
}
Expand Down
Loading