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
2 changes: 1 addition & 1 deletion docs/configuration/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/security-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

Expand Down
13 changes: 12 additions & 1 deletion guard_core/_security_config_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,18 @@ 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 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."
),
)

dynamic_rule_interval: int = Field(
Expand Down
11 changes: 6 additions & 5 deletions guard_core/_utils/request_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
11 changes: 6 additions & 5 deletions guard_core/sync/_utils/request_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
17 changes: 17 additions & 0 deletions tests/test_handlers_integration.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import time
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tests/test_sync/test_handlers_integration.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import time
from collections.abc import Generator
from contextlib import contextmanager
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/test_sync/test_utils/test_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 3 additions & 1 deletion tests/test_utils/test_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading