From 3cf59100b16c72fc4e8be46f3f8f9adf881706ce Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:24:12 +0200 Subject: [PATCH 1/4] fix(logging): keep the reason in the passive-mode suspicious log line A passive-mode suspicious line without trigger info (a rate-limit hit) now reads '[PASSIVE MODE] Suspicious activity detected ... - Reason: ...' instead of dropping the reason under a 'Penetration attempt' label, and the rate-limit stage passes its passive_mode to the log. --- crates/guard-core-rs/src/logging.rs | 56 +++++++++++++++++++++++------ crates/guard-core-rs/src/tower.rs | 34 +++++++++++++++--- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/crates/guard-core-rs/src/logging.rs b/crates/guard-core-rs/src/logging.rs index a78394a..22cbe32 100644 --- a/crates/guard-core-rs/src/logging.rs +++ b/crates/guard-core-rs/src/logging.rs @@ -14,7 +14,10 @@ //! `Suspicious activity detected from {ip}: {method} {url} - Reason: {reason} - Headers: {headers}` //! - `suspicious` (passive): //! `[PASSIVE MODE] Penetration attempt detected from {ip}: {method} {url} - Trigger: {trigger} - Headers: {headers}` -//! (no `Trigger:` segment when `trigger_info` is empty) +//! when `trigger_info` is set; when it is empty (a passive rate-limit +//! hit, for example) the line is +//! `[PASSIVE MODE] Suspicious activity detected from {ip}: {method} {url} - Reason: {reason} - Headers: {headers}`, +//! so the reason is never dropped //! - any other `log_type`: `{Type} from {ip}: {method} {url} - Details: {reason} - Headers: {headers}` //! //! URL and headers run through [`crate::redact`] with the merged @@ -165,8 +168,13 @@ pub fn log_activity( ), LogType::Suspicious => { if passive_mode { + let label = if trigger_info.is_empty() { + "Suspicious activity" + } else { + "Penetration attempt" + }; format!( - "[PASSIVE MODE] Penetration attempt detected from {ip}: {} {}", + "[PASSIVE MODE] {label} detected from {ip}: {} {}", method.unwrap_or_default(), redacted_url.as_deref().unwrap_or_default() ) @@ -192,15 +200,12 @@ pub fn log_activity( } LogType::Suspicious => { if passive_mode { - let mut message = String::new(); - if !trigger_info.is_empty() { - message.push_str("Trigger: "); - message.push_str(trigger_info); - message.push_str(" - "); + let headers = display_headers(redacted_headers.as_ref()); + if trigger_info.is_empty() { + format!("Reason: {reason} - Headers: {headers}") + } else { + format!("Trigger: {trigger_info} - Headers: {headers}") } - message.push_str("Headers: "); - message.push_str(&display_headers(redacted_headers.as_ref())); - message } else { format!( "Reason: {reason} - Headers: {}", @@ -497,7 +502,36 @@ mod tests { &names(), ) .expect("logged"); - assert!(line.ends_with("/q - Headers: {}")); + assert_eq!( + line, + "[PASSIVE MODE] Suspicious activity detected from 192.0.2.1: GET /q - \ + Reason: - Headers: {}" + ); + } + + #[test] + fn passive_without_trigger_keeps_the_reason() { + let line = log_activity( + LogType::Suspicious, + Some(LogLevel::Warning), + "Rate limit exceeded for IP: 192.0.2.1", + Some("192.0.2.1"), + Some("GET"), + Some("/q"), + None, + true, + "", + None, + None, + &names(), + ) + .expect("logged"); + assert_eq!( + line, + "[PASSIVE MODE] Suspicious activity detected from 192.0.2.1: GET /q - \ + Reason: Rate limit exceeded for IP: 192.0.2.1 - Headers: {}" + ); + assert!(!line.contains("Penetration attempt")); } #[test] diff --git a/crates/guard-core-rs/src/tower.rs b/crates/guard-core-rs/src/tower.rs index a8ad4b4..d3d1078 100644 --- a/crates/guard-core-rs/src/tower.rs +++ b/crates/guard-core-rs/src/tower.rs @@ -889,8 +889,8 @@ impl RateLimitStage { /// The reference `log_activity` emission for a crossing: composed with /// this stage's knobs, dropped on the floor when nothing would log. - fn log_rate_limited(&self, ip: IpAddr, decision: &TierDecision) { - let _ = self.compose_suspicious_log( + fn rate_limited_log(&self, ip: IpAddr, decision: &TierDecision) -> Option { + self.compose_suspicious_log( ip, &format!( "Rate limit exceeded for IP: {ip} ({} requests in {}s window)", @@ -898,10 +898,10 @@ impl RateLimitStage { decision.retry_after() ), None, - false, + self.config.passive_mode, "", "rate_limit", - ); + ) } /// The block answer with the reference `custom_body` and `on_block` @@ -971,7 +971,7 @@ impl RateLimitStage { decision: &TierDecision, observation: Option<&RequestObservation>, ) { - self.log_rate_limited(ip, decision); + let _ = self.rate_limited_log(ip, decision); let Some(bus) = &self.events else { return; }; @@ -2025,6 +2025,30 @@ mod tests { assert_eq!(stage.counters().snapshot(exempt).get("xss"), Some(&1)); } + #[test] + fn a_passive_rate_limit_log_names_the_rate_limit() { + let stage = stage_with( + RateLimitStageConfig { + rate_limit: RateLimitConfig { + enable_rate_limiting: true, + rate_limit: 1, + ..RateLimitConfig::default() + }, + ip_ban: IpBanConfig::default(), + passive_mode: true, + custom_error_responses: CustomErrorResponses::default(), + }, + Arc::new(system_clock), + ); + let visitor = ip("192.0.2.40"); + let _ = stage.limiter().check_tiers(visitor, None, None, None); + let decision = stage.limiter().check_tiers(visitor, None, None, None); + let line = stage.rate_limited_log(visitor, &decision).expect("logged"); + assert!(line.starts_with("[PASSIVE MODE] Suspicious activity detected from 192.0.2.40")); + assert!(line.contains("Reason: Rate limit exceeded for IP: 192.0.2.40")); + assert!(!line.contains("Penetration attempt")); + } + #[test] fn passive_mode_records_but_never_blocks_or_feeds_the_autoban() { let fake = FakeClock::default(); From 9638908189e3150bcc2a4f66a5f5d662f9c61bf3 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:24:24 +0200 Subject: [PATCH 2/4] docs(config): list exactly what enable_dynamic_rules lets a rule change The field doc now names the bans, unbans, patterns and the replaced settings, mirroring the reference docs, and says a rule changes nothing else. --- crates/guard-core-engine/src/security_config.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/guard-core-engine/src/security_config.rs b/crates/guard-core-engine/src/security_config.rs index 79ffd8e..ed10533 100644 --- a/crates/guard-core-engine/src/security_config.rs +++ b/crates/guard-core-engine/src/security_config.rs @@ -343,7 +343,17 @@ pub struct SecurityConfig { pub agent_install_id: Option, /// `agent_payload_signing_secret`. pub agent_payload_signing_secret: Option, - /// `enable_dynamic_rules` (requires `enable_agent`). + /// `enable_dynamic_rules` (requires `enable_agent`). Lets the `SaaS` + /// dashboard change protection at runtime without a code change: a rule + /// bans the IPs in its `ip_blacklist` (for `ip_ban_duration` seconds), + /// unbans the IPs in its `ip_whitelist`, adds its `suspicious_patterns` + /// to detection, and replaces `blocked_countries`, `whitelist_countries`, + /// `rate_limit`, `rate_limit_window`, `endpoint_rate_limits`, + /// `block_cloud_providers`, `blocked_user_agents`, + /// `enable_penetration_detection`, `enable_ip_banning`, + /// `enable_rate_limiting`, `enable_rate_limit_auto_ban`, + /// `auto_ban_threshold`, `auto_ban_duration`, `emergency_mode` and + /// `emergency_whitelist`. A rule cannot change any other setting. pub enable_dynamic_rules: bool, /// `dynamic_rule_interval` (seconds, at least 60). pub dynamic_rule_interval: u64, From e4002cb016b38458e9c3f2646cf8ec5f2b2842c4 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:27:13 +0200 Subject: [PATCH 3/4] feat(detection): add an opt-in semantic-only header exclusion Add excluded_semantic_detection_headers (global, route and the detection_exclusion decorator): header names whose values skip the semantic analyzer only, matched case-insensitively after trimming and merged between the global and route sets. Regex category scanning of those headers is unchanged. Default empty. --- crates/guard-core-conformance/src/pipeline.rs | 1 + crates/guard-core-engine/src/decorators.rs | 17 +- crates/guard-core-engine/src/detect.rs | 21 +- .../src/detection_exclusions.rs | 180 +++++++++++++++++- crates/guard-core-engine/src/route_config.rs | 7 + .../guard-core-engine/src/security_config.rs | 8 + 6 files changed, 223 insertions(+), 11 deletions(-) diff --git a/crates/guard-core-conformance/src/pipeline.rs b/crates/guard-core-conformance/src/pipeline.rs index ee412f6..7847665 100644 --- a/crates/guard-core-conformance/src/pipeline.rs +++ b/crates/guard-core-conformance/src/pipeline.rs @@ -418,6 +418,7 @@ impl CaseEngine { excluded_detection_body_fields: list_at("excluded_detection_body_fields"), enabled_detection_categories: None, detection_scan_body: None, + ..RouteDetectionExclusions::default() }; let exclusions = if overrides.get("excluded_detection_headers").is_some() || overrides.get("excluded_detection_params").is_some() diff --git a/crates/guard-core-engine/src/decorators.rs b/crates/guard-core-engine/src/decorators.rs index 817a985..757c69c 100644 --- a/crates/guard-core-engine/src/decorators.rs +++ b/crates/guard-core-engine/src/decorators.rs @@ -555,8 +555,10 @@ impl SecurityDecorator { self } - /// `@detection_exclusion`: the route's five detection knobs; every - /// `None` leaves that knob untouched. + /// `@detection_exclusion`: the route's six detection knobs; every + /// `None` leaves that knob untouched. `semantic_headers` adds header + /// names whose values skip the semantic analyzer only. + #[allow(clippy::too_many_arguments)] pub fn detection_exclusion( &mut self, route_id: &str, @@ -565,9 +567,11 @@ impl SecurityDecorator { body_fields: Option>, categories: Option>, scan_body: Option, + semantic_headers: Option>, ) -> &mut Self { let present = [ headers.is_some(), + semantic_headers.is_some(), params.is_some(), body_fields.is_some(), categories.is_some(), @@ -580,6 +584,9 @@ impl SecurityDecorator { if let Some(headers) = headers { route.excluded_detection_headers = Some(headers); } + if let Some(semantic_headers) = semantic_headers { + route.excluded_semantic_detection_headers = Some(semantic_headers); + } if let Some(params) = params { route.excluded_detection_params = Some(params); } @@ -1125,8 +1132,13 @@ mod tests { None, Some(BTreeSet::from([String::from("xss")])), Some(false), + Some(BTreeSet::from([String::from("cookie")])), ); let route = decorator.get_route_config("POST /scan-me").expect("route"); + assert_eq!( + route.excluded_semantic_detection_headers, + Some(BTreeSet::from([String::from("cookie")])) + ); assert_eq!( route.excluded_detection_headers, Some(BTreeSet::from([String::from("x-api-key")])) @@ -1177,6 +1189,7 @@ mod tests { Some(BTreeSet::from([String::from("password")])), None, None, + None, ); let route = decorator.get_route_config("POST /scan-me").expect("route"); assert_eq!( diff --git a/crates/guard-core-engine/src/detect.rs b/crates/guard-core-engine/src/detect.rs index ff93f0b..acbecb6 100644 --- a/crates/guard-core-engine/src/detect.rs +++ b/crates/guard-core-engine/src/detect.rs @@ -227,6 +227,21 @@ fn semantic_threats( /// The reference `detect` pipeline over one request content. #[must_use] pub fn detect(content: &str, request_context: &str, config: &DetectConfig) -> DetectVerdict { + detect_with_semantic_skip(content, request_context, config, false) +} + +/// [`detect`] with the semantic analyzer optionally skipped. +/// +/// The reference `_context_header_is_semantic_excluded`: the regex passes +/// still run on the value, only the semantic threats are left out. The +/// opt-in `excluded_semantic_detection_headers` path. +#[must_use] +pub fn detect_with_semantic_skip( + content: &str, + request_context: &str, + config: &DetectConfig, + skip_semantic: bool, +) -> DetectVerdict { let original_length = content.chars().count(); let (processed, decoded, decode_budget_exhausted) = preprocessor::preprocess_with_decoded( content, @@ -307,7 +322,11 @@ pub fn detect(content: &str, request_context: &str, config: &DetectConfig) -> De )); } - let semantic = semantic_threats(&processed, content, config); + let semantic = if skip_semantic { + Vec::new() + } else { + semantic_threats(&processed, content, config) + }; let anomaly = regex_anomaly(®ex_threats); let is_threat = anomaly >= config.threat_score_threshold || !semantic.is_empty(); diff --git a/crates/guard-core-engine/src/detection_exclusions.rs b/crates/guard-core-engine/src/detection_exclusions.rs index 50e62bf..fb21ea2 100644 --- a/crates/guard-core-engine/src/detection_exclusions.rs +++ b/crates/guard-core-engine/src/detection_exclusions.rs @@ -123,6 +123,10 @@ pub struct DetectionExclusionConfig { /// `excluded_detection_headers` (merged with the defaults, never a /// replacement). pub excluded_detection_headers: Vec, + /// `excluded_semantic_detection_headers` (opt-in, default empty): + /// header names whose values skip the semantic analyzer only, matched + /// case-insensitively. Regex category scanning is unchanged. + pub excluded_semantic_detection_headers: Vec, /// `excluded_detection_params`. pub excluded_detection_params: Vec, /// `excluded_detection_body_fields`. @@ -143,6 +147,9 @@ pub struct RouteDetectionExclusions { /// `excluded_detection_headers` (merged with the defaults and the /// config set). pub excluded_detection_headers: Option>, + /// `excluded_semantic_detection_headers`: merged with the global set, + /// never a replacement. + pub excluded_semantic_detection_headers: Option>, /// `excluded_detection_params`. pub excluded_detection_params: Option>, /// `excluded_detection_body_fields`. @@ -167,6 +174,9 @@ pub struct ResolvedExclusions { /// Lowercased: header names routed through the excluded-header scan /// (defaults + config + route, merged). pub excluded_headers: HashSet, + /// Lowercased, trimmed: header names whose values skip the semantic + /// analyzer only (global + route, merged). + pub semantic_headers: HashSet, /// Lowercased: the categories that may contribute a detection. `None` /// means every category scans. pub enabled_categories: Option>, @@ -174,6 +184,16 @@ pub struct ResolvedExclusions { pub scan_body: bool, } +/// Merge, trim and lowercase one list into the set +/// (`_resolve_semantic_excluded_headers`). +fn extend_trimmed_lowered(set: &mut HashSet, values: impl IntoIterator) { + set.extend( + values + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()), + ); +} + /// Merge and lowercase one list into the set. fn extend_lowered(set: &mut HashSet, values: impl IntoIterator) { set.extend(values.into_iter().map(|value| value.to_ascii_lowercase())); @@ -203,6 +223,19 @@ pub fn resolve( extend_lowered(&mut resolved.excluded_headers, headers.clone()); } + // Semantic-only header set: additive (global + route), opt-in. + 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()); + } + // Param / body-field / category sets: a non-None route value replaces // the global one. let params: Option> = route @@ -399,6 +432,7 @@ pub fn scan_request( content: &'a str, context: &'a str, skip_categories: &'static [&'static str], + skip_semantic: bool, } let mut values: Vec> = Vec::new(); @@ -407,6 +441,7 @@ pub fn scan_request( content: path, context: "url_path", skip_categories: &[], + skip_semantic: false, }); } for (name, value) in surfaces.query_params { @@ -420,24 +455,31 @@ pub fn scan_request( content: value, context: "query_param", skip_categories: &[], + skip_semantic: false, }); } for (name, value) in surfaces.headers { if value.is_empty() { continue; } - let skip_categories = if exclusions + let skip_semantic = exclusions + .semantic_headers + .contains(&name.trim().to_ascii_lowercase()); + let excluded = exclusions .excluded_headers - .contains(&name.to_ascii_lowercase()) - { - excluded_header_skip_categories(name, value) - } else { - &[] + .contains(&name.to_ascii_lowercase()); + let skip_categories = |value: &str| -> &'static [&'static str] { + if excluded { + excluded_header_skip_categories(name, value) + } else { + &[] + } }; values.push(Value { content: value, context: "header", - skip_categories, + skip_categories: skip_categories(value), + skip_semantic, }); } // The body values borrow the extraction's owned strings, so the owned @@ -463,6 +505,7 @@ pub fn scan_request( content: &value.content, context: &value.context, skip_categories: &[], + skip_semantic: false, }); } @@ -476,7 +519,12 @@ pub fn scan_request( if !budget.admit(value.content) { continue; } - let verdict = detect::detect(value.content, value.context, config); + let verdict = detect::detect_with_semantic_skip( + value.content, + value.context, + config, + value.skip_semantic, + ); if !verdict.is_threat { continue; } @@ -694,6 +742,119 @@ mod tests { assert!(!resolve(Some(&global), None).scan_body); } + /// The Guard-Core/guard-core#161 repro: a JWT session cookie with + /// `theme=system` and `_ga`, which scores 0.711 against the 0.7 + /// semantic threshold when scanned as one value. + 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() + } + + /// Scan one header next to an xss body: a semantic-only hit on the + /// header ends the scan clean (the terminal per-value filter), so the + /// xss body is reached only when the header is not a threat. + 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=", + }, + &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( + "", + "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", + "", + 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()]); + } + #[test] fn the_scan_value_budget_caps_the_values_scanned() { let mut config = corpus_config(); @@ -1032,6 +1193,7 @@ mod gap_tests { 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, }; @@ -1063,6 +1225,7 @@ mod gap_tests { 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, }; @@ -1108,6 +1271,7 @@ mod gap_tests { 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, }, diff --git a/crates/guard-core-engine/src/route_config.rs b/crates/guard-core-engine/src/route_config.rs index 966b3a1..b260341 100644 --- a/crates/guard-core-engine/src/route_config.rs +++ b/crates/guard-core-engine/src/route_config.rs @@ -100,6 +100,8 @@ pub struct RouteConfig { pub geo_rate_limits: Option>, /// `excluded_detection_headers`. pub excluded_detection_headers: Option>, + /// `excluded_semantic_detection_headers`. + pub excluded_semantic_detection_headers: Option>, /// `excluded_detection_params`. pub excluded_detection_params: Option>, /// `excluded_detection_body_fields`. @@ -141,6 +143,7 @@ impl Default for RouteConfig { authorization_header_required: None, geo_rate_limits: None, excluded_detection_headers: None, + excluded_semantic_detection_headers: None, excluded_detection_params: None, excluded_detection_body_fields: None, enabled_detection_categories: None, @@ -186,6 +189,10 @@ impl RouteConfig { .excluded_detection_headers .as_ref() .map(|set| set.iter().cloned().collect()), + excluded_semantic_detection_headers: self + .excluded_semantic_detection_headers + .as_ref() + .map(|set| set.iter().cloned().collect()), excluded_detection_params: self .excluded_detection_params .as_ref() diff --git a/crates/guard-core-engine/src/security_config.rs b/crates/guard-core-engine/src/security_config.rs index ed10533..e420c5b 100644 --- a/crates/guard-core-engine/src/security_config.rs +++ b/crates/guard-core-engine/src/security_config.rs @@ -429,6 +429,12 @@ pub struct SecurityConfig { pub enable_enrichment: bool, /// `excluded_detection_headers`. pub excluded_detection_headers: BTreeSet, + /// `excluded_semantic_detection_headers` (opt-in, default empty): + /// header names whose values skip the semantic analyzer only, matched + /// case-insensitively; regex category scanning is unchanged. Useful for + /// apps whose JWT session cookies score near the semantic threshold + /// (for example `cookie`, `authorization`). + pub excluded_semantic_detection_headers: BTreeSet, /// `excluded_detection_params`. pub excluded_detection_params: BTreeSet, /// `excluded_detection_body_fields`. @@ -586,6 +592,7 @@ impl Default for SecurityConfig { logfire_service_name: String::from("guard-core"), enable_enrichment: false, excluded_detection_headers: BTreeSet::new(), + excluded_semantic_detection_headers: BTreeSet::new(), excluded_detection_params: BTreeSet::new(), excluded_detection_body_fields: BTreeSet::new(), detection_scan_body: true, @@ -992,6 +999,7 @@ mod tests { assert_eq!(config.logfire_service_name, "guard-core"); assert!(!config.enable_enrichment); assert!(config.excluded_detection_headers.is_empty()); + assert!(config.excluded_semantic_detection_headers.is_empty()); assert!(config.excluded_detection_params.is_empty()); assert!(config.excluded_detection_body_fields.is_empty()); assert!(config.detection_scan_body); From e248ed3fb02375a66dfd7d34c4d1f87d6a2f7963 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:28:15 +0200 Subject: [PATCH 4/4] fix(detection): scan the Cookie header one name=value pair at a time As one value, the semicolons between cookies counted as command separators in the semantic score, so an ordinary logged-in header (a JWT session cookie plus theme and _ga) reached 0.711 against the 0.7 threshold. Each pair now takes the normal header path and counts against the value budget. Empty pairs are skipped and only spaces around a pair are trimmed, so a CR or LF stays in the scanned value. Ports guard-core #162. --- .../src/detection_exclusions.rs | 198 ++++++++++++++---- 1 file changed, 162 insertions(+), 36 deletions(-) diff --git a/crates/guard-core-engine/src/detection_exclusions.rs b/crates/guard-core-engine/src/detection_exclusions.rs index fb21ea2..6ec9389 100644 --- a/crates/guard-core-engine/src/detection_exclusions.rs +++ b/crates/guard-core-engine/src/detection_exclusions.rs @@ -338,6 +338,25 @@ pub fn value_looks_like_address_chain(value: &str) -> bool { }) } +/// The values one header contributes to the scan (`_header_items_to_scan`). +/// +/// The `Cookie` header scans one `name=value` pair at a time: as one value, +/// the semicolons between cookies count as command separators in the +/// semantic score. Only spaces around a pair are trimmed (so a CR or LF +/// stays in the scanned value) and empty pairs are skipped. Every other +/// header is a single value. +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] + } +} + /// The request surfaces one scan pass covers (the `detectThreat` input /// shape). Headers and query params are `(name, value)` pairs; body values /// are the [`crate::body_scan`] extraction's output. @@ -408,11 +427,53 @@ impl ScanBudget { true } } +/// One candidate value of the multi-surface scan. +struct ScanValue<'a> { + content: &'a str, + context: &'a str, + skip_categories: &'static [&'static str], + skip_semantic: bool, +} + +/// The header surface: one [`ScanValue`] per [`header_items_to_scan`] item, +/// carrying the excluded-header category skips and the semantic-only skip. +fn push_header_values<'a>( + values: &mut Vec>, + 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, + }); + } + } +} + /// The multi-surface scan (`detectThreat`). /// /// Scan the request surfaces in the reference order - URL path, query /// params (skipping excluded names), headers (excluded names scan with -/// their known-false-positive categories suppressed), then the body surface +/// their known-false-positive categories suppressed, the `Cookie` header one +/// `name=value` pair at a time, headers in the semantic-only set skipping +/// the semantic analyzer), then the body surface /// when [`ResolvedExclusions::scan_body`] is set - and return the first /// value that produces an enabled category. /// @@ -428,16 +489,9 @@ pub fn scan_request( exclusions: &ResolvedExclusions, config: &DetectConfig, ) -> RequestScanVerdict { - struct Value<'a> { - content: &'a str, - context: &'a str, - skip_categories: &'static [&'static str], - skip_semantic: bool, - } - - let mut values: Vec> = Vec::new(); + let mut values: Vec> = Vec::new(); if let Some(path) = surfaces.url_path.filter(|path| !path.is_empty()) { - values.push(Value { + values.push(ScanValue { content: path, context: "url_path", skip_categories: &[], @@ -451,37 +505,14 @@ pub fn scan_request( { continue; } - values.push(Value { + values.push(ScanValue { content: value, context: "query_param", skip_categories: &[], skip_semantic: false, }); } - for (name, value) in surfaces.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()); - let skip_categories = |value: &str| -> &'static [&'static str] { - if excluded { - excluded_header_skip_categories(name, value) - } else { - &[] - } - }; - values.push(Value { - content: value, - context: "header", - skip_categories: skip_categories(value), - skip_semantic, - }); - } + push_header_values(&mut values, surfaces.headers, exclusions); // The body values borrow the extraction's owned strings, so the owned // vector is kept in a binding that outlives the scan loop. let body_values: Vec = if exclusions.scan_body { @@ -501,7 +532,7 @@ pub fn scan_request( // categories. return forced_verdict(core::slice::from_ref(&forced)); } - values.push(Value { + values.push(ScanValue { content: &value.content, context: &value.context, skip_categories: &[], @@ -855,6 +886,101 @@ mod tests { assert_eq!(verdict.categories, vec!["xss".to_owned()]); } + /// One cookie pair that trips the semantic analyzer alone (no regex + /// category), to prove the semantic opt-out applies per pair. + const SEMANTIC_ONLY_PAIR: &str = "${x} (y) [z] {w} a://b c://d call(f(x)) {{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.categories.is_empty(), + "control: the same value in another header scans as one value" + ); + } + + #[test] + fn an_attack_in_one_cookie_is_still_detected() { + for (cookie, category) in [ + ("theme=dark; q=", "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.categories.is_empty(), + "control: the pair trips the semantic analyzer and ends the scan" + ); + 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=")], + &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", " ; ;; ;")], &config); + assert_eq!( + verdict.categories, + vec!["xss".to_owned()], + "empty pairs take no budget" + ); + } + #[test] fn the_scan_value_budget_caps_the_values_scanned() { let mut config = corpus_config();