From e9a2c54b4f51d3e51b3d06f9c04afb3847b93283 Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 14 Oct 2026 21:40:34 +0200 Subject: [PATCH 1/2] fix(logging): keep the reason on passive suspicious logs and document dynamic rules A passive-mode rate-limit hit was logged as a penetration attempt with the reason dropped. Passive suspicious lines without a trigger now read 'Suspicious activity detected' and carry the reason. enable_dynamic_rules now lists the settings the dashboard can change. --- docs/configuration/logging.md | 2 +- docs/configuration/security-config.md | 2 +- .../skills/guard-core/references/config.md | 2 +- guard_core/_security_config_fields.py | 10 +++++++++- guard_core/_utils/request_logging.py | 11 ++++++----- guard_core/sync/_utils/request_logging.py | 11 ++++++----- tests/test_handlers_integration.py | 17 +++++++++++++++++ tests/test_sync/test_handlers_integration.py | 17 +++++++++++++++++ tests/test_sync/test_utils/test_logging.py | 4 +++- tests/test_utils/test_logging.py | 4 +++- 10 files changed, 64 insertions(+), 16 deletions(-) diff --git a/docs/configuration/logging.md b/docs/configuration/logging.md index 200e369b..a1b10a44 100644 --- a/docs/configuration/logging.md +++ b/docs/configuration/logging.md @@ -157,7 +157,7 @@ async def log_activity( | `"suspicious"` | `Suspicious activity detected from {ip}: {method} {url} - Reason: {reason} - Headers: {...}` | | Other | `{Type} from {ip}: {method} {url} - Details: {reason} - Headers: {...}` | -**Passive mode**: When `passive_mode=True` and `log_type="suspicious"`, the message is prefixed with `[PASSIVE MODE] Penetration attempt detected`, in the format `[PASSIVE MODE] Penetration attempt detected from {ip}: {method} {url} - Trigger: {trigger_info} - Headers: {...}`. The `Trigger: {trigger_info}` segment only appears when `trigger_info` is non-empty. +**Passive mode**: When `passive_mode=True` and `log_type="suspicious"`, the message is prefixed with `[PASSIVE MODE] Penetration attempt detected` when `trigger_info` is set, in the format `[PASSIVE MODE] Penetration attempt detected from {ip}: {method} {url} - Trigger: {trigger_info} - Headers: {...}`. When `trigger_info` is empty (for example a passive rate-limit hit), the line is `[PASSIVE MODE] Suspicious activity detected from {ip}: {method} {url} - Reason: {reason} - Headers: {...}`, so the reason is never dropped. **Level `None`**: When `level` is `None`, the function returns immediately without logging. diff --git a/docs/configuration/security-config.md b/docs/configuration/security-config.md index ef8ddaff..6b8133ab 100644 --- a/docs/configuration/security-config.md +++ b/docs/configuration/security-config.md @@ -477,7 +477,7 @@ Dynamic Rules | Field | Type | Default | Description | |-------------------------|--------|---------|-------------------------------------------------| -| `enable_dynamic_rules` | `bool` | `False` | Enable dynamic rule updates from SaaS platform. | +| `enable_dynamic_rules` | `bool` | `False` | Enable dynamic rule updates from the SaaS platform. Enabling it lets the dashboard change these settings at runtime without a code change: blocked_countries, whitelist_countries, rate_limit, rate_limit_window, endpoint_rate_limits, block_cloud_providers, 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. Requires enable_agent. Rules only mutate the live config, so a rule cannot add settings outside this list. | | `dynamic_rule_interval` | `int` | `300` | Seconds between rule update checks. | | `dynamic_rules_cache_path` | `Path \| None` | `None` | Optional local JSON file persisting the last-known dynamic rules snapshot so a restart during a SaaS outage restores the last applied rules instead of base config. Redis holds the primary snapshot whenever a redis_handler is present; the file is an additional opt-in fallback that is only written and read when this path is set. Neither store expires: the Redis key and the file persist until you remove them, even after you disable dynamic rules. | | `emergency_mode` | `bool` | `False` | Emergency lockdown mode (set by dynamic rules). | diff --git a/guard_core/.agents/skills/guard-core/references/config.md b/guard_core/.agents/skills/guard-core/references/config.md index 8773b013..a957528d 100644 --- a/guard_core/.agents/skills/guard-core/references/config.md +++ b/guard_core/.agents/skills/guard-core/references/config.md @@ -59,7 +59,7 @@ In-memory rate-limit stores (`RateLimitManager.request_timestamps` and the modul `to_agent_config()` builds an `AgentConfig` from the agent fields when `enable_agent=True` and `agent_api_key` is set, else returns `None`. The ten `agent_*` fields listed above with a `None` default (plus `on_error`) are each omitted from the `AgentConfig(...)` call when unset, so `AgentConfig`'s own default applies instead of a duplicated value drifting out of sync with guard-agent. -`dynamic_rule_interval` (Interval in seconds between dynamic rule updates). +`enable_dynamic_rules` (default `False`; requires `enable_agent`) lets the SaaS dashboard change these settings at runtime without a code change: `blocked_countries`, `whitelist_countries`, `rate_limit`, `rate_limit_window`, `endpoint_rate_limits`, `block_cloud_providers`, `enable_penetration_detection`, `enable_ip_banning`, `enable_rate_limiting`, `enable_rate_limit_auto_ban`, `auto_ban_threshold`, `auto_ban_duration`, `emergency_mode`, `emergency_whitelist`. `dynamic_rule_interval` (Interval in seconds between dynamic rule updates). ### Pipeline behavior diff --git a/guard_core/_security_config_fields.py b/guard_core/_security_config_fields.py index 64649a63..ac8e7350 100644 --- a/guard_core/_security_config_fields.py +++ b/guard_core/_security_config_fields.py @@ -701,7 +701,15 @@ class _SecurityConfigFields(BaseModel): ) enable_dynamic_rules: bool = Field( - default=False, description="Enable dynamic rule updates from SaaS platform" + default=False, + description=( + "Lets the SaaS dashboard change these settings at runtime without a " + "code change: blocked_countries, whitelist_countries, rate_limit, " + "rate_limit_window, endpoint_rate_limits, block_cloud_providers, " + "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. Requires enable_agent." + ), ) dynamic_rule_interval: int = Field( diff --git a/guard_core/_utils/request_logging.py b/guard_core/_utils/request_logging.py index d300e6e3..95b67737 100644 --- a/guard_core/_utils/request_logging.py +++ b/guard_core/_utils/request_logging.py @@ -367,15 +367,16 @@ def _build_log_message_for_suspicious( context: dict[str, Any], reason: str, passive_mode: bool, trigger_info: str ) -> tuple[str, str]: if passive_mode: - message = "[PASSIVE MODE] Penetration attempt detected from" + label = "Penetration attempt" if trigger_info else "Suspicious activity" + message = f"[PASSIVE MODE] {label} detected from" details = ( f"{message} {context['client_ip']}: {context['method']} {context['url']}" ) - trigger_message = f"Trigger: {trigger_info}" if trigger_info else "" - reason_message = f"Headers: {context['headers']}" - if trigger_message: - reason_message = f"{trigger_message} - {reason_message}" + if trigger_info: + reason_message = f"Trigger: {trigger_info} - Headers: {context['headers']}" + else: + reason_message = f"Reason: {reason} - Headers: {context['headers']}" else: message = "Suspicious activity detected from" details = ( diff --git a/guard_core/sync/_utils/request_logging.py b/guard_core/sync/_utils/request_logging.py index b7bbb99d..e0a50238 100644 --- a/guard_core/sync/_utils/request_logging.py +++ b/guard_core/sync/_utils/request_logging.py @@ -370,15 +370,16 @@ def _build_log_message_for_suspicious( context: dict[str, Any], reason: str, passive_mode: bool, trigger_info: str ) -> tuple[str, str]: if passive_mode: - message = "[PASSIVE MODE] Penetration attempt detected from" + label = "Penetration attempt" if trigger_info else "Suspicious activity" + message = f"[PASSIVE MODE] {label} detected from" details = ( f"{message} {context['client_ip']}: {context['method']} {context['url']}" ) - trigger_message = f"Trigger: {trigger_info}" if trigger_info else "" - reason_message = f"Headers: {context['headers']}" - if trigger_message: - reason_message = f"{trigger_message} - {reason_message}" + if trigger_info: + reason_message = f"Trigger: {trigger_info} - Headers: {context['headers']}" + else: + reason_message = f"Reason: {reason} - Headers: {context['headers']}" else: message = "Suspicious activity detected from" details = ( diff --git a/tests/test_handlers_integration.py b/tests/test_handlers_integration.py index 9137e111..e8e43ed2 100644 --- a/tests/test_handlers_integration.py +++ b/tests/test_handlers_integration.py @@ -1,3 +1,4 @@ +import logging import time from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -686,6 +687,22 @@ async def test_ratelimit_handle_exceeded_with_agent() -> None: assert result.status_code == 429 +async def test_ratelimit_exceeded_passive_log_names_rate_limit( + caplog: pytest.LogCaptureFixture, +) -> None: + config = SecurityConfig(enable_redis=False, passive_mode=True) + mgr = RateLimitManager(config) + req = MockGuardRequest() + + from tests.conftest import MockGuardResponse + + create_error = AsyncMock(return_value=MockGuardResponse("rate limited", 429)) + with caplog.at_level(logging.WARNING): + await mgr._handle_rate_limit_exceeded(req, "1.2.3.4", 10, create_error) + assert "Rate limit exceeded for IP: 1.2.3.4" in caplog.text + assert "Penetration attempt" not in caplog.text + + async def test_responses_factory_cors_disabled() -> None: from guard_core.core.events.metrics import MetricsCollector from guard_core.core.responses.context import ResponseContext diff --git a/tests/test_sync/test_handlers_integration.py b/tests/test_sync/test_handlers_integration.py index 7ba0c981..f16286c6 100644 --- a/tests/test_sync/test_handlers_integration.py +++ b/tests/test_sync/test_handlers_integration.py @@ -1,3 +1,4 @@ +import logging import time from collections.abc import Generator from contextlib import contextmanager @@ -680,6 +681,22 @@ def test_ratelimit_handle_exceeded_with_agent() -> None: assert result.status_code == 429 +def test_ratelimit_exceeded_passive_log_names_rate_limit( + caplog: pytest.LogCaptureFixture, +) -> None: + config = SecurityConfig(enable_redis=False, passive_mode=True) + mgr = RateLimitManager(config) + req = SyncMockGuardRequest() + + from tests.test_sync.conftest import MockGuardResponse + + create_error = MagicMock(return_value=MockGuardResponse("rate limited", 429)) + with caplog.at_level(logging.WARNING): + mgr._handle_rate_limit_exceeded(req, "1.2.3.4", 10, create_error) + assert "Rate limit exceeded for IP: 1.2.3.4" in caplog.text + assert "Penetration attempt" not in caplog.text + + def test_responses_factory_cors_disabled() -> None: from guard_core.sync.core.events.metrics import MetricsCollector from guard_core.sync.core.responses.context import ResponseContext diff --git a/tests/test_sync/test_utils/test_logging.py b/tests/test_sync/test_utils/test_logging.py index f94e7bf8..cc0b516d 100644 --- a/tests/test_sync/test_utils/test_logging.py +++ b/tests/test_sync/test_utils/test_logging.py @@ -945,7 +945,9 @@ def test_log_suspicious_activity_passive_mode_without_trigger_info_omits_trigger trigger_info="", ) - assert "[PASSIVE MODE] Penetration attempt detected from" in caplog.text + assert "[PASSIVE MODE] Suspicious activity detected from" in caplog.text + assert "Penetration attempt" not in caplog.text + assert "Reason: Suspicious activity detected" in caplog.text assert "Trigger:" not in caplog.text diff --git a/tests/test_utils/test_logging.py b/tests/test_utils/test_logging.py index d432d410..8b34db4f 100644 --- a/tests/test_utils/test_logging.py +++ b/tests/test_utils/test_logging.py @@ -951,7 +951,9 @@ async def test_log_suspicious_activity_passive_mode_without_trigger_info_omits_t trigger_info="", ) - assert "[PASSIVE MODE] Penetration attempt detected from" in caplog.text + assert "[PASSIVE MODE] Suspicious activity detected from" in caplog.text + assert "Penetration attempt" not in caplog.text + assert "Reason: Suspicious activity detected" in caplog.text assert "Trigger:" not in caplog.text From 91c2cef336dd660bbccdb8eaadf4b63787d3aa5e Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 02:22:31 +0200 Subject: [PATCH 2/2] docs(config): list IP bans, unbans, user agents and patterns among dynamic rule effects --- docs/configuration/security-config.md | 2 +- .../.agents/skills/guard-core/references/config.md | 2 +- guard_core/_security_config_fields.py | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/configuration/security-config.md b/docs/configuration/security-config.md index 6b8133ab..d17aa408 100644 --- a/docs/configuration/security-config.md +++ b/docs/configuration/security-config.md @@ -477,7 +477,7 @@ Dynamic Rules | Field | Type | Default | Description | |-------------------------|--------|---------|-------------------------------------------------| -| `enable_dynamic_rules` | `bool` | `False` | Enable dynamic rule updates from the SaaS platform. Enabling it lets the dashboard change these settings at runtime without a code change: blocked_countries, whitelist_countries, rate_limit, rate_limit_window, endpoint_rate_limits, block_cloud_providers, 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. Requires enable_agent. Rules only mutate the live config, so a rule cannot add settings outside this list. | +| `enable_dynamic_rules` | `bool` | `False` | Enable dynamic rule updates from the SaaS platform. Enabling it lets the 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. Requires enable_agent. A rule cannot change any other setting. | | `dynamic_rule_interval` | `int` | `300` | Seconds between rule update checks. | | `dynamic_rules_cache_path` | `Path \| None` | `None` | Optional local JSON file persisting the last-known dynamic rules snapshot so a restart during a SaaS outage restores the last applied rules instead of base config. Redis holds the primary snapshot whenever a redis_handler is present; the file is an additional opt-in fallback that is only written and read when this path is set. Neither store expires: the Redis key and the file persist until you remove them, even after you disable dynamic rules. | | `emergency_mode` | `bool` | `False` | Emergency lockdown mode (set by dynamic rules). | diff --git a/guard_core/.agents/skills/guard-core/references/config.md b/guard_core/.agents/skills/guard-core/references/config.md index a957528d..404cd4bf 100644 --- a/guard_core/.agents/skills/guard-core/references/config.md +++ b/guard_core/.agents/skills/guard-core/references/config.md @@ -59,7 +59,7 @@ In-memory rate-limit stores (`RateLimitManager.request_timestamps` and the modul `to_agent_config()` builds an `AgentConfig` from the agent fields when `enable_agent=True` and `agent_api_key` is set, else returns `None`. The ten `agent_*` fields listed above with a `None` default (plus `on_error`) are each omitted from the `AgentConfig(...)` call when unset, so `AgentConfig`'s own default applies instead of a duplicated value drifting out of sync with guard-agent. -`enable_dynamic_rules` (default `False`; requires `enable_agent`) lets the SaaS dashboard change these settings at runtime without a code change: `blocked_countries`, `whitelist_countries`, `rate_limit`, `rate_limit_window`, `endpoint_rate_limits`, `block_cloud_providers`, `enable_penetration_detection`, `enable_ip_banning`, `enable_rate_limiting`, `enable_rate_limit_auto_ban`, `auto_ban_threshold`, `auto_ban_duration`, `emergency_mode`, `emergency_whitelist`. `dynamic_rule_interval` (Interval in seconds between dynamic rule updates). +`enable_dynamic_rules` (default `False`; 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`, `emergency_whitelist`. `dynamic_rule_interval` (Interval in seconds between dynamic rule updates). ### Pipeline behavior diff --git a/guard_core/_security_config_fields.py b/guard_core/_security_config_fields.py index ac8e7350..6891b397 100644 --- a/guard_core/_security_config_fields.py +++ b/guard_core/_security_config_fields.py @@ -703,9 +703,12 @@ class _SecurityConfigFields(BaseModel): enable_dynamic_rules: bool = Field( default=False, description=( - "Lets the SaaS dashboard change these settings at runtime without a " - "code change: blocked_countries, whitelist_countries, rate_limit, " - "rate_limit_window, endpoint_rate_limits, block_cloud_providers, " + "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. Requires enable_agent."