use std::collections::HashSet;
use crate::body_scan::BodyScanValue;
use crate::detect::{self, DetectConfig, Threat};
use crate::scan_budget::{DEFAULT_MAX_SCAN_SECONDS, ScanTimeBudget, budget_exhausted_message};
const SSRF_SKIP: &[&str] = &["ssrf"];
pub const DEFAULT_EXCLUDED_HEADERS: &[&str] = &[
"host",
"user-agent",
"accept",
"accept-encoding",
"connection",
"origin",
"referer",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-real-ip",
"x-client-ip",
"x-cluster-client-ip",
"cf-connecting-ip",
"true-client-ip",
"fly-client-ip",
"x-envoy-external-address",
];
const ADDRESS_HEADER_NAMES: &[&str] = &[
"host",
"origin",
"x-forwarded-for",
"x-forwarded-host",
"x-real-ip",
"x-client-ip",
"x-cluster-client-ip",
"cf-connecting-ip",
"true-client-ip",
"fly-client-ip",
"x-envoy-external-address",
"via",
];
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DetectionExclusionConfig {
pub excluded_detection_headers: Vec<String>,
pub excluded_semantic_detection_headers: Vec<String>,
pub excluded_detection_params: Vec<String>,
pub excluded_detection_body_fields: Vec<String>,
pub enabled_detection_categories: Option<Vec<String>>,
pub detection_scan_body: Option<bool>,
pub max_scan_seconds: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RouteDetectionExclusions {
pub excluded_detection_headers: Option<Vec<String>>,
pub excluded_semantic_detection_headers: Option<Vec<String>>,
pub excluded_detection_params: Option<Vec<String>>,
pub excluded_detection_body_fields: Option<Vec<String>>,
pub enabled_detection_categories: Option<Vec<String>>,
pub detection_scan_body: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ResolvedExclusions {
pub excluded_params: HashSet<String>,
pub excluded_body_fields: HashSet<String>,
pub excluded_headers: HashSet<String>,
pub semantic_headers: HashSet<String>,
pub enabled_categories: Option<HashSet<String>>,
pub scan_body: bool,
pub max_scan_seconds: Option<f64>,
}
fn extend_trimmed_lowered(set: &mut HashSet<String>, values: impl IntoIterator<Item = String>) {
set.extend(
values
.into_iter()
.map(|value| value.trim().to_ascii_lowercase()),
);
}
fn extend_lowered(set: &mut HashSet<String>, values: impl IntoIterator<Item = String>) {
set.extend(values.into_iter().map(|value| value.to_ascii_lowercase()));
}
#[must_use]
pub fn resolve(
config: Option<&DetectionExclusionConfig>,
route: Option<&RouteDetectionExclusions>,
) -> ResolvedExclusions {
let mut resolved = ResolvedExclusions::default();
resolved
.excluded_headers
.extend(DEFAULT_EXCLUDED_HEADERS.iter().map(|h| (*h).to_owned()));
if let Some(config) = config {
extend_lowered(
&mut resolved.excluded_headers,
config.excluded_detection_headers.clone(),
);
}
if let Some(headers) = route.and_then(|route| route.excluded_detection_headers.as_ref()) {
extend_lowered(&mut resolved.excluded_headers, headers.clone());
}
if let Some(config) = config {
extend_trimmed_lowered(
&mut resolved.semantic_headers,
config.excluded_semantic_detection_headers.clone(),
);
}
if let Some(headers) =
route.and_then(|route| route.excluded_semantic_detection_headers.as_ref())
{
extend_trimmed_lowered(&mut resolved.semantic_headers, headers.clone());
}
let params: Option<Vec<String>> = route
.and_then(|route| route.excluded_detection_params.clone())
.or_else(|| config.map(|config| config.excluded_detection_params.clone()));
if let Some(params) = params {
extend_lowered(&mut resolved.excluded_params, params);
}
let body_fields: Option<Vec<String>> = route
.and_then(|route| route.excluded_detection_body_fields.clone())
.or_else(|| config.map(|config| config.excluded_detection_body_fields.clone()));
if let Some(body_fields) = body_fields {
extend_lowered(&mut resolved.excluded_body_fields, body_fields);
}
let categories: Option<Vec<String>> = route
.and_then(|route| route.enabled_detection_categories.clone())
.or_else(|| config.and_then(|config| config.enabled_detection_categories.clone()));
if let Some(categories) = categories {
let mut set = HashSet::new();
extend_lowered(&mut set, categories);
resolved.enabled_categories = Some(set);
}
resolved.scan_body = route
.and_then(|route| route.detection_scan_body)
.or_else(|| config.and_then(|config| config.detection_scan_body))
.unwrap_or(true);
resolved.max_scan_seconds = config.and_then(|config| config.max_scan_seconds);
resolved
}
#[must_use]
pub fn excluded_header_skip_categories(name: &str, value: &str) -> &'static [&'static str] {
let normalized = name.trim().to_ascii_lowercase();
if ADDRESS_HEADER_NAMES.contains(&normalized.as_str()) {
return SSRF_SKIP;
}
if value_looks_like_address_chain(value) {
return SSRF_SKIP;
}
&[]
}
#[must_use]
pub fn strip_forwarded_entry_port(value: &str) -> &str {
if let Some(rest) = value.strip_prefix('[') {
let Some(closing) = rest.find(']') else {
return value;
};
let remainder = &rest[closing + 1..];
let port_valid =
remainder.starts_with(':') && remainder[1..].bytes().all(|b| b.is_ascii_digit());
if !(remainder.is_empty() || port_valid) {
return value;
}
return &rest[..closing];
}
if value.matches(':').count() == 1
&& let Some((host, port)) = value.split_once(':')
&& !port.is_empty()
&& port.bytes().all(|b| b.is_ascii_digit())
{
return host;
}
value
}
#[must_use]
pub fn value_looks_like_address_chain(value: &str) -> bool {
let tokens: Vec<&str> = value
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty())
.collect();
if tokens.is_empty() {
return false;
}
tokens.iter().all(|token| {
strip_forwarded_entry_port(token)
.parse::<std::net::IpAddr>()
.is_ok()
})
}
fn header_items_to_scan<'a>(name: &str, value: &'a str) -> Vec<&'a str> {
if name.trim().eq_ignore_ascii_case("cookie") {
value
.split(';')
.map(|pair| pair.trim_matches(' '))
.filter(|pair| !pair.is_empty())
.collect()
} else {
vec![value]
}
}
#[derive(Debug, Clone, Default)]
pub struct RequestSurfaces<'a> {
pub url_path: Option<&'a str>,
pub query_params: &'a [(String, String)],
pub headers: &'a [(String, String)],
pub content_type: &'a str,
pub raw_body: &'a str,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestScanVerdict {
pub is_threat: bool,
pub categories: Vec<String>,
pub reason: String,
}
struct ScanBudget {
max_values: usize,
scanned_values: usize,
max_chars: usize,
scanned_chars: usize,
}
impl ScanBudget {
const fn new(config: &DetectConfig) -> Self {
Self {
max_values: config.max_scan_values,
scanned_values: 0,
max_chars: config.max_scan_chars,
scanned_chars: 0,
}
}
fn admit(&mut self, content: &str) -> bool {
self.scanned_values += 1;
if self.scanned_values > self.max_values {
return false;
}
let value_chars = content.chars().count();
if self.scanned_chars >= self.max_chars {
return false;
}
self.scanned_chars += value_chars;
true
}
}
struct ScanValue<'a> {
content: &'a str,
context: &'a str,
skip_categories: &'static [&'static str],
skip_semantic: bool,
}
fn push_header_values<'a>(
values: &mut Vec<ScanValue<'a>>,
headers: &'a [(String, String)],
exclusions: &ResolvedExclusions,
) {
for (name, value) in headers {
if value.is_empty() {
continue;
}
let skip_semantic = exclusions
.semantic_headers
.contains(&name.trim().to_ascii_lowercase());
let excluded = exclusions
.excluded_headers
.contains(&name.to_ascii_lowercase());
for pair in header_items_to_scan(name, value) {
values.push(ScanValue {
content: pair,
context: "header",
skip_categories: if excluded {
excluded_header_skip_categories(name, pair)
} else {
&[]
},
skip_semantic,
});
}
}
}
#[must_use]
pub fn scan_request(
surfaces: &RequestSurfaces<'_>,
exclusions: &ResolvedExclusions,
config: &DetectConfig,
) -> RequestScanVerdict {
let mut values: Vec<ScanValue<'_>> = Vec::new();
if let Some(path) = surfaces.url_path.filter(|path| !path.is_empty()) {
values.push(ScanValue {
content: path,
context: "url_path",
skip_categories: &[],
skip_semantic: false,
});
}
for (name, value) in surfaces.query_params {
if exclusions
.excluded_params
.contains(&name.to_ascii_lowercase())
{
continue;
}
values.push(ScanValue {
content: value,
context: "query_param",
skip_categories: &[],
skip_semantic: false,
});
}
push_header_values(&mut values, surfaces.headers, exclusions);
let body_values: Vec<BodyScanValue> = if exclusions.scan_body {
crate::body_scan::extract_body_scan_values_with_exclusions(
surfaces.raw_body,
surfaces.content_type,
config,
crate::body_scan::ExcludedBodyFields::new(&exclusions.excluded_body_fields),
)
} else {
Vec::new()
};
for value in &body_values {
if let Some(forced) = value.forced_category {
return forced_verdict(core::slice::from_ref(&forced));
}
values.push(ScanValue {
content: &value.content,
context: &value.context,
skip_categories: &[],
skip_semantic: false,
});
}
let mut budget = ScanBudget::new(config);
let mut time_budget = ScanTimeBudget::new(
exclusions
.max_scan_seconds
.unwrap_or(DEFAULT_MAX_SCAN_SECONDS),
);
for value in &values {
if !budget.admit(value.content) {
continue;
}
let verdict = detect::detect_with_budget(
value.content,
value.context,
config,
value.skip_semantic,
&mut time_budget,
);
if !verdict.is_threat {
continue;
}
if verdict
.threats
.iter()
.any(|threat| matches!(threat, Threat::PatternTimeout(_)))
{
return budget_exhausted_verdict(&verdict, exclusions, value.skip_categories);
}
let mut categories: Vec<String> = Vec::new();
for threat in &verdict.threats {
let category = match threat {
Threat::Regex(regex) | Threat::PatternTimeout(regex) => {
if !category_enabled(®ex.category, exclusions.enabled_categories.as_ref())
|| value.skip_categories.contains(®ex.category.as_str())
{
continue;
}
regex.category.clone()
}
Threat::Semantic(semantic) => semantic.attack_type.clone(),
};
if !categories.contains(&category) {
categories.push(category);
}
}
if verdict.threats.is_empty() {
categories.push("uncategorized".to_owned());
}
if categories.is_empty() {
continue;
}
return verdict_from(categories);
}
RequestScanVerdict::default()
}
fn budget_exhausted_verdict(
verdict: &detect::DetectVerdict,
exclusions: &ResolvedExclusions,
skip_categories: &[&str],
) -> RequestScanVerdict {
let mut matched: Vec<String> = Vec::new();
for threat in &verdict.threats {
if let Threat::Regex(regex) = threat
&& category_enabled(®ex.category, exclusions.enabled_categories.as_ref())
&& !skip_categories.contains(®ex.category.as_str())
&& !matched.contains(®ex.category)
{
matched.push(regex.category.clone());
}
}
let budget_message = budget_exhausted_message();
let reason = if matched.is_empty() {
budget_message
} else {
matched.sort();
format!(
"Penetration patterns detected: {}; {budget_message}",
matched.join(", ")
)
};
let mut categories = matched;
categories.push("custom".to_owned());
categories.sort();
categories.dedup();
RequestScanVerdict {
is_threat: true,
categories,
reason,
}
}
fn forced_verdict(categories: &[&str]) -> RequestScanVerdict {
let categories: Vec<String> = categories.iter().map(|c| (*c).to_owned()).collect();
verdict_from(categories)
}
fn verdict_from(mut categories: Vec<String>) -> RequestScanVerdict {
categories.sort();
let reason = format!("Penetration patterns detected: {}", categories.join(", "));
RequestScanVerdict {
is_threat: true,
categories,
reason,
}
}
fn category_enabled(category: &str, enabled: Option<&HashSet<String>>) -> bool {
enabled.is_none_or(|set| set.contains(category))
}
#[must_use]
pub fn detection_enabled(global_enabled: bool, route_enabled: Option<bool>) -> bool {
route_enabled.unwrap_or(global_enabled)
}
#[must_use]
pub fn check_applies(
global_enabled: bool,
route_toggles: impl IntoIterator<Item = bool>,
dynamic_rules: bool,
) -> bool {
global_enabled || route_toggles.into_iter().any(|flag| flag) || dynamic_rules
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detect::DetectConfig;
fn corpus_config() -> DetectConfig {
DetectConfig {
max_content_length: 10_000,
max_full_scan_bytes: 262_144,
preserve_attack_patterns: true,
semantic_threshold: 0.7,
threat_score_threshold: 1.0,
binary_min_run_length: 16,
max_scan_values: 512,
max_scan_chars: 65_536,
max_json_depth: 32,
}
}
fn config_of(params: &[&str]) -> DetectionExclusionConfig {
DetectionExclusionConfig {
excluded_detection_params: params.iter().map(|s| (*s).to_owned()).collect(),
..DetectionExclusionConfig::default()
}
}
#[test]
fn resolve_defaults_scan_everything() {
let resolved = resolve(None, None);
assert!(resolved.excluded_params.is_empty());
assert!(resolved.excluded_body_fields.is_empty());
assert!(resolved.enabled_categories.is_none());
assert!(resolved.scan_body);
assert!(resolved.excluded_headers.contains("host"));
assert!(resolved.excluded_headers.contains("x-forwarded-for"));
assert!(!resolved.excluded_headers.contains("x-custom"));
}
#[test]
fn non_none_route_sets_replace_and_unset_inherit() {
let global = config_of(&["debug", "trace"]);
let route = RouteDetectionExclusions {
excluded_detection_params: Some(vec!["session".to_owned()]),
..RouteDetectionExclusions::default()
};
let resolved = resolve(Some(&global), Some(&route));
assert!(resolved.excluded_params.contains("session"));
assert!(!resolved.excluded_params.contains("debug"));
let inheriting = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
assert!(inheriting.excluded_params.contains("debug"));
assert!(inheriting.excluded_params.contains("trace"));
}
#[test]
fn header_set_merges_defaults_config_and_route() {
let global = DetectionExclusionConfig {
excluded_detection_headers: vec!["X-Custom-Global".to_owned()],
..DetectionExclusionConfig::default()
};
let route = RouteDetectionExclusions {
excluded_detection_headers: Some(vec!["x-custom-route".to_owned()]),
..RouteDetectionExclusions::default()
};
let resolved = resolve(Some(&global), Some(&route));
assert!(resolved.excluded_headers.contains("x-custom-global"));
assert!(resolved.excluded_headers.contains("x-custom-route"));
assert!(
resolved.excluded_headers.contains("referer"),
"defaults survive"
);
}
#[test]
fn empty_route_category_set_disables_every_category() {
let route = RouteDetectionExclusions {
enabled_detection_categories: Some(Vec::new()),
..RouteDetectionExclusions::default()
};
let resolved = resolve(None, Some(&route));
let enabled = resolved.enabled_categories.clone().expect("some");
assert!(enabled.is_empty(), "an empty Some replaces, not inherits");
assert!(!category_enabled(
"sqli",
resolved.enabled_categories.as_ref()
));
let global = DetectionExclusionConfig {
enabled_detection_categories: Some(vec!["sqli".to_owned()]),
..DetectionExclusionConfig::default()
};
let inheriting = resolve(Some(&global), None);
assert!(category_enabled(
"sqli",
inheriting.enabled_categories.as_ref()
));
assert!(!category_enabled(
"xss",
inheriting.enabled_categories.as_ref()
));
}
#[test]
fn scan_body_toggle_overrides_both_directions() {
let global = DetectionExclusionConfig {
detection_scan_body: Some(true),
..DetectionExclusionConfig::default()
};
let route = RouteDetectionExclusions {
detection_scan_body: Some(false),
..RouteDetectionExclusions::default()
};
assert!(!resolve(Some(&global), Some(&route)).scan_body);
let global = DetectionExclusionConfig {
detection_scan_body: Some(false),
..DetectionExclusionConfig::default()
};
let route = RouteDetectionExclusions {
detection_scan_body: Some(true),
..RouteDetectionExclusions::default()
};
assert!(resolve(Some(&global), Some(&route)).scan_body);
let global = DetectionExclusionConfig {
detection_scan_body: Some(false),
..DetectionExclusionConfig::default()
};
assert!(!resolve(Some(&global), None).scan_body);
}
const SESSION_COOKIE: &str = "session=eyJhbGciOiAiRVMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiM2YyYzFiOWUtOGE0Ny00ZDJlLTljMWYtNWI2YTdkOGU5ZjAxIiwgIm5hbWUiOiAiSmFuZSBEb2UgKEFjbWUpIiwgImVtYWlsIjogImphbmUuZG9lQGV4YW1wbGUuY29tIiwgInJvbGVzIjogWyJhZG1pbiJdLCAiaWF0IjogMTc5MjAwMDAwMCwgImV4cCI6IDE3OTIwMDA5MDB9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-Pw; theme=system; _ga=GA1.1.123456789.1792000000";
fn semantic_threat_count(content: &str, skip_semantic: bool) -> usize {
crate::detect::detect_with_semantic_skip(content, "header", &corpus_config(), skip_semantic)
.threats
.iter()
.filter(|threat| matches!(threat, Threat::Semantic(_)))
.count()
}
fn scan_header_with_xss_body(
name: &str,
value: &str,
global: Option<&DetectionExclusionConfig>,
route: Option<&RouteDetectionExclusions>,
) -> RequestScanVerdict {
let headers = vec![(name.to_owned(), value.to_owned())];
scan_request(
&RequestSurfaces {
url_path: None,
query_params: &[],
headers: &headers,
content_type: "application/x-www-form-urlencoded",
raw_body: "q=<script>alert(1)</script>",
},
&resolve(global, route),
&corpus_config(),
)
}
#[test]
fn the_semantic_skip_drops_only_the_semantic_threats() {
assert!(
semantic_threat_count(SESSION_COOKIE, false) > 0,
"control: the unsplit session value trips the semantic analyzer"
);
assert_eq!(semantic_threat_count(SESSION_COOKIE, true), 0);
let verdict = crate::detect::detect_with_semantic_skip(
"<script>alert(1)</script>",
"header",
&corpus_config(),
true,
);
assert!(
verdict
.threats
.iter()
.any(|threat| matches!(threat, Threat::Regex(regex) if regex.category == "xss")),
"regex scanning is unchanged"
);
}
#[test]
fn semantic_headers_resolve_lowercased_trimmed_and_merged() {
let global = DetectionExclusionConfig {
excluded_semantic_detection_headers: vec![" X-Global ".to_owned()],
..DetectionExclusionConfig::default()
};
let route = RouteDetectionExclusions {
excluded_semantic_detection_headers: Some(vec!["X-Route".to_owned()]),
..RouteDetectionExclusions::default()
};
let merged = resolve(Some(&global), Some(&route));
assert_eq!(
merged.semantic_headers,
HashSet::from(["x-global".to_owned(), "x-route".to_owned()])
);
let inherited = resolve(Some(&global), Some(&RouteDetectionExclusions::default()));
assert_eq!(
inherited.semantic_headers,
HashSet::from(["x-global".to_owned()])
);
assert!(resolve(None, None).semantic_headers.is_empty(), "opt-in");
}
#[test]
fn a_global_semantic_header_skips_only_the_semantic_analyzer() {
let global = DetectionExclusionConfig {
excluded_semantic_detection_headers: vec!["X-Session".to_owned()],
..DetectionExclusionConfig::default()
};
let verdict = scan_header_with_xss_body("x-session", SESSION_COOKIE, Some(&global), None);
assert_eq!(verdict.categories, vec!["xss".to_owned()]);
let attack = scan_header_with_xss_body(
"x-session",
"<img src=x onerror=alert(1)>",
Some(&global),
None,
);
assert_eq!(
attack.categories,
vec!["xss".to_owned()],
"regex scanning of the header is unchanged"
);
}
#[test]
fn a_route_semantic_header_skips_the_semantic_analyzer() {
let route = RouteDetectionExclusions {
excluded_semantic_detection_headers: Some(vec!["x-session".to_owned()]),
..RouteDetectionExclusions::default()
};
let verdict = scan_header_with_xss_body("X-Session", SESSION_COOKIE, None, Some(&route));
assert_eq!(verdict.categories, vec!["xss".to_owned()]);
}
const SEMANTIC_ONLY_PAIR: &str = "${x} <t> (y) [z] {w} a://b c://d <b>call(f(x))</b> {{render(jinja(template(mustache(handlebars(ejs(pug(twig)))))))}}";
fn scan_headers(headers: &[(&str, &str)], config: &DetectConfig) -> RequestScanVerdict {
let headers: Vec<(String, String)> = headers
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
.collect();
scan_request(
&RequestSurfaces {
url_path: None,
query_params: &[],
headers: &headers,
content_type: "",
raw_body: "",
},
&resolve(None, None),
config,
)
}
#[test]
fn a_logged_in_cookie_header_is_not_flagged() {
let cookie = scan_header_with_xss_body("Cookie", SESSION_COOKIE, None, None);
assert_eq!(
cookie.categories,
vec!["xss".to_owned()],
"each pair scans on its own, so the session cookie is not a threat"
);
let other = scan_header_with_xss_body("x-session", SESSION_COOKIE, None, None);
assert!(
other.is_threat
&& !other.categories.is_empty()
&& other.categories != cookie.categories,
"control: the same value in another header scans as one value and is reported on its own: {:?}",
other.categories
);
}
#[test]
fn an_attack_in_one_cookie_is_still_detected() {
for (cookie, category) in [
("theme=dark; q=<script>alert(1)</script>", "xss"),
("theme=dark; id=1' OR 1=1--", "sqli"),
("theme=dark;\r\nSet-Cookie: admin=1", "http_split"),
] {
let verdict = scan_headers(&[("Cookie", cookie)], &corpus_config());
assert!(
verdict.categories.iter().any(|found| found == category),
"{cookie:?} must detect {category}, got {:?}",
verdict.categories
);
}
}
#[test]
fn the_semantic_exclusion_applies_to_each_cookie_pair() {
let cookie = format!("theme=dark; {SEMANTIC_ONLY_PAIR}");
let blocked = scan_header_with_xss_body("cookie", &cookie, None, None);
assert!(
blocked.is_threat && !blocked.categories.is_empty() && blocked.categories != ["xss"],
"control: the pair trips the semantic analyzer and is reported: {:?}",
blocked.categories
);
let global = DetectionExclusionConfig {
excluded_semantic_detection_headers: vec!["Cookie".to_owned()],
..DetectionExclusionConfig::default()
};
let verdict = scan_header_with_xss_body("cookie", &cookie, Some(&global), None);
assert_eq!(verdict.categories, vec!["xss".to_owned()]);
}
#[test]
fn each_cookie_pair_counts_against_the_value_budget() {
let mut config = corpus_config();
config.max_scan_values = 2;
let verdict = scan_headers(
&[("cookie", "a=1; b=2; c=<script>alert(1)</script>")],
&config,
);
assert!(
verdict.categories.is_empty(),
"the pair holding the attack is the third value, past the cap"
);
}
#[test]
fn empty_cookie_pairs_are_skipped_and_only_spaces_are_trimmed() {
let mut config = corpus_config();
config.max_scan_values = 1;
let verdict = scan_headers(&[("cookie", " ; ;; <script>alert(1)</script> ;")], &config);
assert_eq!(
verdict.categories,
vec!["xss".to_owned()],
"empty pairs take no budget"
);
}
fn benign_surfaces() -> RequestSurfaces<'static> {
RequestSurfaces {
url_path: Some("/items"),
query_params: &[],
headers: &[],
content_type: "",
raw_body: "",
}
}
fn zero_budget_exclusions(categories: Option<Vec<String>>) -> ResolvedExclusions {
resolve(
Some(&DetectionExclusionConfig {
enabled_detection_categories: categories,
max_scan_seconds: Some(0.0),
..DetectionExclusionConfig::default()
}),
None,
)
}
#[test]
fn the_scan_time_cap_resolves_from_the_global_config_only() {
assert_eq!(resolve(None, None).max_scan_seconds, None);
let global = DetectionExclusionConfig {
max_scan_seconds: Some(2.5),
..DetectionExclusionConfig::default()
};
assert_eq!(resolve(Some(&global), None).max_scan_seconds, Some(2.5));
let route = RouteDetectionExclusions::default();
assert_eq!(
resolve(Some(&global), Some(&route)).max_scan_seconds,
Some(2.5)
);
}
#[test]
fn an_exhausted_scan_time_budget_blocks_and_names_the_budget() {
let verdict = scan_request(
&benign_surfaces(),
&zero_budget_exclusions(None),
&corpus_config(),
);
assert!(verdict.is_threat);
assert_eq!(verdict.categories, vec!["custom".to_owned()]);
assert_eq!(
verdict.reason,
"Pattern exceeded scan time budget: 'scan_time_budget_exhausted'"
);
}
#[test]
fn the_budget_threat_follows_the_normal_threshold_rule() {
let mut config = corpus_config();
config.threat_score_threshold = 5.0;
let verdict = scan_request(&benign_surfaces(), &zero_budget_exclusions(None), &config);
assert!(!verdict.is_threat);
assert!(verdict.reason.is_empty());
}
#[test]
fn the_budget_threat_is_not_dropped_by_the_category_filter() {
let exclusions = zero_budget_exclusions(Some(vec!["xss".to_owned()]));
let verdict = scan_request(&benign_surfaces(), &exclusions, &corpus_config());
assert!(verdict.is_threat);
assert!(verdict.reason.contains("scan time budget"));
}
#[test]
fn the_budget_reason_keeps_the_categories_the_value_already_matched() {
let verdict = crate::detect::DetectVerdict {
is_threat: true,
threat_score: 1.0,
threats: vec![
Threat::Regex(crate::patterns::RegexThreat {
pattern: "p".to_owned(),
match_text: "m".to_owned(),
position: 0,
category: "xss".to_owned(),
weight: 1.0,
}),
Threat::PatternTimeout(crate::patterns::scan_time_budget_exhausted_threat()),
],
original_length: 1,
processed_length: 1,
};
let out = budget_exhausted_verdict(&verdict, &resolve(None, None), &[]);
assert_eq!(out.categories, vec!["custom".to_owned(), "xss".to_owned()]);
assert_eq!(
out.reason,
"Penetration patterns detected: xss; Pattern exceeded scan time budget: \
'scan_time_budget_exhausted'"
);
let filtered = budget_exhausted_verdict(&verdict, &resolve(None, None), &["xss"]);
assert_eq!(filtered.categories, vec!["custom".to_owned()]);
}
#[test]
fn the_scan_time_budget_is_shared_by_every_value_of_the_request() {
let surfaces = RequestSurfaces {
url_path: Some("/items"),
query_params: &[("q".to_owned(), "benign".to_owned())],
headers: &[],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &zero_budget_exclusions(None), &corpus_config());
assert!(
verdict.is_threat,
"the first value already trips the budget"
);
}
#[test]
fn the_default_scan_time_budget_leaves_a_benign_request_clean() {
let verdict = scan_request(&benign_surfaces(), &resolve(None, None), &corpus_config());
assert!(!verdict.is_threat);
assert!(verdict.reason.is_empty());
}
#[test]
fn the_scan_value_budget_caps_the_values_scanned() {
let mut config = corpus_config();
config.max_scan_values = 2;
let exclusions = resolve(None, None);
let surfaces = RequestSurfaces {
url_path: Some("/scan"),
query_params: &[
("a".to_owned(), "benign-one".to_owned()),
("b".to_owned(), "benign-two".to_owned()),
("c".to_owned(), "1 UNION SELECT password".to_owned()),
("d".to_owned(), "benign-four".to_owned()),
("e".to_owned(), "<script>alert(1)</script>".to_owned()),
],
headers: &[],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &config);
assert!(
!verdict.is_threat,
"values past the detection_max_scan_values cap are not scanned"
);
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat);
}
#[test]
fn the_scan_char_budget_caps_the_characters_scanned() {
let mut config = corpus_config();
config.max_scan_chars = "benign-one".chars().count() + "benign-two".chars().count();
let exclusions = resolve(None, None);
let surfaces = RequestSurfaces {
url_path: Some("/scan"),
query_params: &[
("a".to_owned(), "benign-one".to_owned()),
("b".to_owned(), "benign-two".to_owned()),
("c".to_owned(), "1 UNION SELECT password".to_owned()),
],
headers: &[],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &config);
assert!(
!verdict.is_threat,
"values past the detection_max_scan_chars cap are not scanned"
);
}
#[test]
fn the_budgets_count_every_candidate_even_skipped_ones() {
let mut config = corpus_config();
config.max_scan_values = 3;
config.max_scan_chars = 0;
let exclusions = resolve(None, None);
let surfaces = RequestSurfaces {
url_path: Some("/scan"),
query_params: &[
("a".to_owned(), "1 UNION SELECT password".to_owned()),
("b".to_owned(), "<script>alert(1)</script>".to_owned()),
("c".to_owned(), "benign".to_owned()),
],
headers: &[],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &config);
assert!(
!verdict.is_threat,
"a zero char budget skips every value (the count still ran)"
);
}
#[test]
fn scan_body_false_still_scans_headers_params_and_path() {
let route = RouteDetectionExclusions {
detection_scan_body: Some(false),
..RouteDetectionExclusions::default()
};
let exclusions = resolve(None, Some(&route));
let surfaces = RequestSurfaces {
url_path: Some("/files?x=../../etc/passwd"),
query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
headers: &[(
"x-payload".to_owned(),
"<script>alert(1)</script>".to_owned(),
)],
content_type: "application/x-www-form-urlencoded",
raw_body: "payload=<img src=x onerror=alert(1)>",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(
verdict.is_threat,
"the body surface is skipped, the rest still detect"
);
}
#[test]
fn excluded_param_skips_only_itself() {
let global = config_of(&["debug"]);
let exclusions = resolve(Some(&global), None);
let surfaces = RequestSurfaces {
url_path: Some("/search"),
query_params: &[
("debug".to_owned(), "1 UNION SELECT password".to_owned()),
("q".to_owned(), "<script>alert(1)</script>".to_owned()),
],
headers: &[],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat);
assert!(
!verdict.categories.contains(&"sqli".to_owned()),
"the excluded param did not contribute"
);
let surfaces = RequestSurfaces {
url_path: Some("/search"),
query_params: &[("DEBUG".to_owned(), "1 UNION SELECT password".to_owned())],
headers: &[],
content_type: "",
raw_body: "",
};
assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
}
#[test]
fn excluded_body_field_skips_only_that_field() {
let global = DetectionExclusionConfig {
excluded_detection_body_fields: vec!["secret".to_owned()],
..DetectionExclusionConfig::default()
};
let exclusions = resolve(Some(&global), None);
let surfaces = RequestSurfaces {
url_path: Some("/api"),
query_params: &[],
headers: &[],
content_type: "application/x-www-form-urlencoded",
raw_body: "secret=1 UNION SELECT password&safe=<script>alert(1)</script>",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat, "the untouched field still scans");
assert_eq!(verdict.categories, vec!["xss"]);
let surfaces = RequestSurfaces {
url_path: Some("/api"),
query_params: &[],
headers: &[],
content_type: "application/x-www-form-urlencoded",
raw_body: "secret=1 UNION SELECT password",
};
assert!(!scan_request(&surfaces, &exclusions, &corpus_config()).is_threat);
}
#[test]
fn excluded_header_with_attack_payload_still_detects() {
let exclusions = resolve(None, None);
let surfaces = RequestSurfaces {
url_path: Some("/x"),
query_params: &[],
headers: &[(
"x-custom".to_owned(),
"<script>alert(1)</script>".to_owned(),
)],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat, "an excluded header is not a skip hole");
assert_eq!(verdict.categories, vec!["xss"]);
}
#[test]
fn address_chain_headers_skip_ssrf_only() {
let chain = "10.0.0.5, 172.16.0.1";
assert_eq!(
excluded_header_skip_categories("x-custom", chain),
&["ssrf"][..]
);
assert_eq!(
excluded_header_skip_categories("via", chain),
&["ssrf"][..],
"the address-carrying name skips for any value"
);
assert_eq!(
excluded_header_skip_categories("x-custom", "<script>alert(1)</script>"),
&[] as &[&str],
"a non-address value skips nothing"
);
assert_eq!(
excluded_header_skip_categories("x-custom", "10.0.0.5, not-an-ip"),
&[] as &[&str],
"one non-address token breaks the chain"
);
}
#[test]
fn port_stripping_feeds_the_address_parser() {
assert_eq!(strip_forwarded_entry_port("1.2.3.4:8080"), "1.2.3.4");
assert_eq!(strip_forwarded_entry_port("[::1]:8080"), "::1");
assert_eq!(strip_forwarded_entry_port("::1"), "::1");
assert_eq!(strip_forwarded_entry_port("not-an-ip"), "not-an-ip");
assert_eq!(
strip_forwarded_entry_port("[::1]:nope"),
"[::1]:nope",
"a non-numeric port keeps the value unparsed"
);
assert!(
value_looks_like_address_chain("1.2.3.4:8080, [::1]:8080"),
"socket-literal entries parse once the port is stripped"
);
}
#[test]
fn filtered_out_threat_alone_is_clean() {
let route = RouteDetectionExclusions {
enabled_detection_categories: Some(vec!["xss".to_owned()]),
..RouteDetectionExclusions::default()
};
let exclusions = resolve(None, Some(&route));
let surfaces = RequestSurfaces {
url_path: Some("/x"),
query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
headers: &[],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(!verdict.is_threat);
assert!(verdict.categories.is_empty());
}
const XSS: &str = "<script>alert(1)</script>";
fn semantic_only_value() -> String {
SESSION_COOKIE.to_owned()
}
fn scan_surfaces(
exclusions: &ResolvedExclusions,
query: &[(String, String)],
headers: &[(String, String)],
) -> RequestScanVerdict {
let surfaces = RequestSurfaces {
url_path: Some("/x"),
query_params: query,
headers,
content_type: "",
raw_body: "",
};
scan_request(&surfaces, exclusions, &corpus_config())
}
#[test]
fn semantic_only_value_is_reported() {
let query = [("q".to_owned(), semantic_only_value())];
let verdict = scan_surfaces(&resolve(None, None), &query, &[]);
assert!(verdict.is_threat, "semantic-only threat must be reported");
assert!(!verdict.categories.is_empty());
}
#[test]
fn filtered_out_value_does_not_end_the_scan() {
let headers = [
(
"x-forwarded-host".to_owned(),
"http://169.254.169.254/latest/meta-data/".to_owned(),
),
("x-evil".to_owned(), XSS.to_owned()),
];
let verdict = scan_surfaces(&resolve(None, None), &[], &headers);
assert_eq!(verdict.categories, vec!["xss".to_owned()]);
}
#[test]
fn disabled_category_does_not_end_the_scan() {
let route = RouteDetectionExclusions {
enabled_detection_categories: Some(vec!["xss".to_owned()]),
..RouteDetectionExclusions::default()
};
let query = [("q".to_owned(), "1 UNION SELECT password".to_owned())];
let headers = [("x-evil".to_owned(), XSS.to_owned())];
let verdict = scan_surfaces(&resolve(None, Some(&route)), &query, &headers);
assert_eq!(verdict.categories, vec!["xss".to_owned()]);
}
#[test]
fn a_threat_without_a_threats_list_reports_as_uncategorized() {
let config = DetectConfig {
threat_score_threshold: 0.0,
..corpus_config()
};
let query = [("q".to_owned(), "hello".to_owned())];
let surfaces = RequestSurfaces {
url_path: None,
query_params: &query,
headers: &[],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &resolve(None, None), &config);
assert!(verdict.is_threat);
assert_eq!(verdict.categories, vec!["uncategorized".to_owned()]);
}
#[test]
fn semantic_then_header_xss_is_still_reported() {
let query = [("q".to_owned(), semantic_only_value())];
let headers = [("x-evil".to_owned(), XSS.to_owned())];
let verdict = scan_surfaces(&resolve(None, None), &query, &headers);
assert!(verdict.is_threat);
}
#[test]
fn enabled_categories_route_the_verdict() {
let route = RouteDetectionExclusions {
enabled_detection_categories: Some(vec!["sqli".to_owned()]),
..RouteDetectionExclusions::default()
};
let exclusions = resolve(None, Some(&route));
let surfaces = RequestSurfaces {
url_path: Some("/x"),
query_params: &[("q".to_owned(), "1 UNION SELECT password".to_owned())],
headers: &[(
"x-payload".to_owned(),
"<script>alert(1)</script>".to_owned(),
)],
content_type: "",
raw_body: "",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat);
assert_eq!(verdict.categories, vec!["sqli"]);
assert!(verdict.reason.contains("sqli"));
assert!(!verdict.reason.contains("xss"), "xss is not enabled here");
}
#[test]
fn mongo_operator_keys_hit_through_the_body_walk() {
let exclusions = resolve(None, None);
let surfaces = RequestSurfaces {
url_path: Some("/x"),
query_params: &[],
headers: &[],
content_type: "application/json",
raw_body: "{\"$where\": \"1 OR 1=1\"}",
};
let verdict = scan_request(&surfaces, &exclusions, &corpus_config());
assert!(verdict.is_threat);
assert_eq!(verdict.categories, vec!["nosql"]);
}
#[test]
fn route_toggles_and_liveness_mirror_the_reference() {
assert!(detection_enabled(false, Some(true)));
assert!(!detection_enabled(true, Some(false)));
assert!(detection_enabled(true, None));
assert!(!detection_enabled(false, None));
assert!(check_applies(false, [false, true], false));
assert!(!check_applies(false, [false, false], false));
assert!(check_applies(true, [], false));
assert!(
check_applies(false, [false], true),
"dynamic rules keep it alive"
);
}
}
#[cfg(test)]
mod gap_tests {
use super::*;
fn config() -> DetectConfig {
DetectConfig {
max_content_length: 10_000,
max_full_scan_bytes: 262_144,
preserve_attack_patterns: true,
semantic_threshold: 0.7,
threat_score_threshold: 1.0,
binary_min_run_length: 16,
max_scan_values: 512,
max_scan_chars: 65_536,
max_json_depth: 32,
}
}
#[test]
fn an_unterminated_bracket_entry_keeps_its_port_text() {
assert_eq!(strip_forwarded_entry_port("[::1"), "[::1");
}
#[test]
fn a_chain_of_only_empty_tokens_is_not_an_address_chain() {
assert!(!value_looks_like_address_chain(" , ,, "));
}
#[test]
fn empty_header_values_never_scan() {
let exclusions = ResolvedExclusions {
excluded_params: std::collections::HashSet::new(),
excluded_headers: std::collections::HashSet::new(),
excluded_body_fields: std::collections::HashSet::new(),
semantic_headers: std::collections::HashSet::new(),
enabled_categories: None,
scan_body: false,
max_scan_seconds: None,
};
let headers = vec![
("x-empty".to_owned(), String::new()),
("x-attack".to_owned(), "10.0.0.5, 127.0.0.1".to_owned()),
];
let verdict = scan_request(
&RequestSurfaces {
url_path: None,
query_params: &[],
headers: &headers,
content_type: "",
raw_body: "",
},
&exclusions,
&config(),
);
assert!(verdict.is_threat);
assert_eq!(verdict.categories, vec!["ssrf".to_owned()]);
}
#[test]
fn an_excluded_address_header_suppresses_its_ssrf_category() {
let mut excluded_headers = std::collections::HashSet::new();
excluded_headers.insert("x-forwarded-for".to_owned());
let exclusions = ResolvedExclusions {
excluded_params: std::collections::HashSet::new(),
excluded_headers,
excluded_body_fields: std::collections::HashSet::new(),
semantic_headers: std::collections::HashSet::new(),
enabled_categories: None,
scan_body: false,
max_scan_seconds: None,
};
let headers = vec![(
"x-forwarded-for".to_owned(),
"10.0.0.5, 127.0.0.1".to_owned(),
)];
let verdict = scan_request(
&RequestSurfaces {
url_path: None,
query_params: &[],
headers: &headers,
content_type: "",
raw_body: "",
},
&exclusions,
&config(),
);
assert!(!verdict.is_threat);
assert!(verdict.categories.is_empty());
}
#[test]
fn a_semantic_threat_reports_its_attack_type_beside_the_regex_categories() {
let content = format!(
"select union insert update delete drop from where order group having concat \
substring database table column (1 OR 1=1) {}",
"A".repeat(120)
);
let verdict = scan_request(
&RequestSurfaces {
url_path: None,
query_params: &[],
headers: &[],
content_type: "text/plain",
raw_body: &content,
},
&ResolvedExclusions {
excluded_params: std::collections::HashSet::new(),
excluded_headers: std::collections::HashSet::new(),
excluded_body_fields: std::collections::HashSet::new(),
semantic_headers: std::collections::HashSet::new(),
enabled_categories: None,
scan_body: true,
max_scan_seconds: None,
},
&config(),
);
assert!(verdict.is_threat);
assert_eq!(
verdict.categories,
vec!["sql".to_owned(), "sqli".to_owned()]
);
}
}