Skip to content

Commit 8b7e989

Browse files
committed
Merge master (PR #39) into release/v3.5.0-hardening
2 parents f6310b7 + 4b7f5c3 commit 8b7e989

47 files changed

Lines changed: 3830 additions & 222 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ Cloud-IP region scoping, IP allow-list correctness, bounded body inspection, asy
125125

126126
___
127127

128+
v3.1.2 (2026-06-08)
129+
-------------------
130+
131+
Configurable country-check logging (v3.1.2)
132+
-------------------------------------------
133+
134+
### Changed
135+
136+
- **Per-request country verdicts are now configurable and routed through the `guard_core` logger.** `_log_country_check_result` previously logged whitelisted / not-affected results at `INFO` straight to the root logger (`logging.info`), so it was unreachable by named-logger levels and by `log_suspicious_level` / `log_request_level`. It now logs via `logging.getLogger("guard_core")` and honours the new `SecurityConfig.log_country_check_level` (default `"INFO"`, preserving prior behaviour). Set it to `None` to silence the routine whitelisted / not-affected chatter while keeping the genuine signal. Continuing the noise reduction from v3.1.0: blocked-country hits still log at `WARNING`, and no-rules / no-geolocation cases still log at `DEBUG`. Sync mirror updated identically.
137+
- **Penetration-detection hits now honour `log_suspicious_level`.** The per-component "Potential attack detected from …" line in `_check_request_component` was a second suspicious-log path that logged at a hardcoded `WARNING` on the root logger, bypassing config and duplicating the authoritative `SuspiciousActivityCheck` log. It now routes through the `guard_core` logger at `log_suspicious_level` (threaded down from `detect_penetration_attempt`); setting `log_suspicious_level=None` silences it.
138+
- **All remaining bare root-logger calls in `utils.py` and `cloud_handler.py` moved onto named `guard_core` loggers** (`Error processing client IP`, `Error checking IP`, `Enhanced detection failed`, and the cloud-provider `Failed to fetch … IP ranges` errors). No behavioural change at default levels — `logging.getLogger("guard_core").setLevel(...)` now governs the whole library. Sync mirror updated identically.
139+
140+
___
141+
128142
v3.1.1 (2026-05-27)
129143
-------------------
130144

docs/architecture/pipeline.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ This check runs first because all subsequent checks depend on `request.state.cli
259259
| **Module** | `guard_core.core.checks.implementations.cloud_ip_refresh` |
260260
| **Purpose** | Periodically refreshes cloud provider IP ranges based on `config.cloud_ip_refresh_interval` |
261261
| **Blocks?** | Never. Always returns `None` |
262-
| **Side Effects** | Triggers `middleware.refresh_cloud_ip_ranges()` when the interval has elapsed |
262+
| **Side Effects** | Schedules a single-flight background refresh (running `middleware.refresh_cloud_ip_ranges()` off the request path) when the interval has elapsed |
263263

264264
### 12. IpSecurityCheck
265265

docs/configuration/security-config.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,17 @@ ___
283283
Redis
284284
-----
285285

286-
| Field | Type | Default | Description |
287-
|----------------|---------------|---------------------------|---------------------------------------|
288-
| `enable_redis` | `bool` | `True` | Master switch for Redis. |
289-
| `redis_url` | `str \| None` | `"redis://localhost:6379"`| Redis connection URL. |
290-
| `redis_prefix` | `str` | `"guard_core:"` | Key prefix for namespace isolation. |
286+
| Field | Type | Default | Description |
287+
|----------------------------------|---------------|---------------------------|----------------------------------------------------------|
288+
| `enable_redis` | `bool` | `True` | Master switch for Redis. |
289+
| `redis_url` | `str \| None` | `"redis://localhost:6379"`| Redis connection URL. |
290+
| `redis_prefix` | `str` | `"guard_core:"` | Key prefix for namespace isolation. |
291+
| `redis_socket_connect_timeout` | `float \| None`| `2.0` | Seconds to wait establishing a TCP connection. Must be positive; `None` disables (blocks indefinitely on a partitioned Redis). |
292+
| `redis_socket_timeout` | `float \| None`| `2.0` | Seconds to wait on a read/write before raising. Must be positive; `None` means no timeout. |
293+
| `redis_health_check_interval` | `int` | `30` | Seconds between pooled-connection health checks. `0` disables. |
294+
| `redis_max_connections` | `int \| None` | `None` | Cap on the connection pool size. `None` uses redis-py's default. |
295+
| `redis_retries` | `int` | `1` | Retries (with exponential backoff) on transient connection/timeout errors. `0` disables. |
296+
| `redis_fail_open` | `bool` | `False` | On Redis outage, `fail_secure` governs by default. Set `True` to skip the failing check and let the request through, treating Redis outages as an availability concern distinct from other check failures. |
291297

292298
___
293299

@@ -318,6 +324,7 @@ Logging
318324
|-----------------------|-------------------------------------------------|------------|------------------------------------------|
319325
| `log_suspicious_level`| `"INFO" \| "DEBUG" \| "WARNING" \| "ERROR" \| "CRITICAL" \| None` | `"WARNING"` | Log level for suspicious requests. `None` disables. |
320326
| `log_request_level` | Same as above | `None` | Log level for all requests. `None` disables. |
327+
| `log_country_check_level` | Same as above (default `"INFO"`) | `"INFO"` | Log level for non-block country verdicts (whitelisted / not-affected). `None` disables. Blocked-country hits always log at `WARNING`; no-rules / no-geolocation always log at `DEBUG`. |
321328
| `log_format` | `"text" \| "json"` | `"text"` | Log output format. |
322329
| `custom_log_file` | `str \| None` | `None` | Path to a custom log file. |
323330

docs/internals/check-implementations.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,9 @@ ___
199199

200200
**Purpose**: Periodically refreshes cloud provider IP ranges.
201201

202-
**Blocks**: Never.
202+
**Blocks**: Never. The refresh runs as a single-flight background task (via `cloud_handler.schedule_refresh`, which invokes the middleware's `refresh_cloud_ip_ranges()`), so the request that crosses the interval is never delayed by provider fetches.
203203

204-
**Triggers when**: `config.block_cloud_providers` is set and `cloud_ip_refresh_interval` seconds have elapsed since the last refresh.
204+
**Triggers when**: `config.block_cloud_providers` is set and `cloud_ip_refresh_interval` seconds have elapsed since the last refresh. If scheduling fails, the debounce timestamp is restored so the next request retries.
205205

206206
___
207207

docs/internals/cloud-providers.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,17 +214,27 @@ cloud_ip_refresh_interval: int = Field(
214214
)
215215
```
216216

217-
The `CloudIpRefreshCheck` pipeline check triggers a refresh when enough time has elapsed:
217+
The `CloudIpRefreshCheck` pipeline check schedules a refresh when enough time has elapsed:
218218

219219
```python
220220
class CloudIpRefreshCheck(SecurityCheck):
221221
async def check(self, request: GuardRequest) -> GuardResponse | None:
222+
if not self.config.block_cloud_providers:
223+
return None
224+
222225
if (
223-
self.config.block_cloud_providers
224-
and time.time() - self.middleware.last_cloud_ip_refresh
226+
time.time() - self.middleware.last_cloud_ip_refresh
225227
> self.config.cloud_ip_refresh_interval
226228
):
227-
await self.middleware.refresh_cloud_ip_ranges()
229+
previous_refresh = self.middleware.last_cloud_ip_refresh
230+
self.middleware.last_cloud_ip_refresh = int(time.time())
231+
scheduled = await cloud_handler.schedule_refresh(
232+
{str(provider) for provider in self.config.block_cloud_providers},
233+
ttl=self.config.cloud_ip_refresh_interval,
234+
refresh=self.middleware.refresh_cloud_ip_ranges,
235+
)
236+
if not scheduled:
237+
self.middleware.last_cloud_ip_refresh = previous_refresh
228238
return None
229239
```
230240

@@ -233,6 +243,20 @@ This check runs on every request but only performs work when:
233243
- `block_cloud_providers` is configured.
234244
- The elapsed time since the last refresh exceeds `cloud_ip_refresh_interval`.
235245

246+
The refresh itself never runs on the request path. `schedule_refresh` fires the
247+
middleware's `refresh_cloud_ip_ranges()` as a single-flight background task:
248+
while one refresh is in flight, further calls are no-ops, so a slow provider
249+
fetch cannot block or stampede request handling. The debounce timestamp is
250+
bumped up front so concurrent requests don't all try to schedule, and restored
251+
if scheduling fails so the next request retries instead of waiting a full
252+
interval. Because the background task calls the middleware protocol method,
253+
adapter overrides of `refresh_cloud_ip_ranges` stay on the periodic path.
254+
255+
The in-memory cloud-IP store honors the `ttl` passed at refresh time (the
256+
Redis-backed store always did), so cached ranges expire after
257+
`cloud_ip_refresh_interval` and the next refresh fetches fresh data in
258+
non-Redis deployments too.
259+
236260
The default interval is **3600 seconds (1 hour)**. The minimum allowed value is **60 seconds**.
237261

238262
### Range Change Logging

docs/internals/redis.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ redis_manager = RedisManager(config)
3636
await redis_manager.initialize()
3737
```
3838

39-
Creates a `redis.asyncio.Redis` connection from `config.redis_url` with `decode_responses=True`. Pings to verify connectivity. Raises `GuardRedisError(503)` on failure.
39+
Creates a `redis.asyncio.Redis` connection from `config.redis_url` with `decode_responses=True`, applying the configured socket timeouts (`redis_socket_timeout`, `redis_socket_connect_timeout`), pool cap (`redis_max_connections`), health-check interval (`redis_health_check_interval`), and — when `redis_retries > 0` — a client-level `Retry` with exponential backoff on connection/timeout errors. Pings to verify connectivity. Raises `GuardRedisError(503)` on failure.
40+
41+
Note that the client-level retry re-sends non-idempotent commands: a lost reply after the server already committed an `INCR` over-counts by one. For guard-core's rate-limit counters that fails closed (mildly over-restrictive, self-heals next window); callers needing exactly-once semantics should not build on `incr()`.
4042

4143
**Close**:
4244

@@ -134,16 +136,24 @@ class GuardRedisError(GuardCoreError):
134136

135137
Adapters should catch `GuardRedisError` during initialization and handle it according to their framework's error model.
136138

139+
When a `GuardRedisError` escapes a security check at request time (a Redis outage mid-request), the pipeline honors `fail_secure` by default: the request is blocked with a 500. Setting `redis_fail_open=True` opts into skipping the failing check and letting the request through, treating Redis outages as an availability concern distinct from other check failures.
140+
137141
___
138142

139143
Configuration
140144
-------------
141145

142-
| Field | Type | Default | Description |
143-
|----------------|---------------|---------------------------|------------------------------------------|
144-
| `enable_redis` | `bool` | `True` | Master switch for Redis integration |
145-
| `redis_url` | `str \| None` | `"redis://localhost:6379"`| Redis connection URL |
146-
| `redis_prefix` | `str` | `"guard_core:"` | Key prefix for namespace isolation |
146+
| Field | Type | Default | Description |
147+
|----------------------------------|----------------|---------------------------|------------------------------------------|
148+
| `enable_redis` | `bool` | `True` | Master switch for Redis integration |
149+
| `redis_url` | `str \| None` | `"redis://localhost:6379"`| Redis connection URL |
150+
| `redis_prefix` | `str` | `"guard_core:"` | Key prefix for namespace isolation |
151+
| `redis_socket_connect_timeout` | `float \| None`| `2.0` | Seconds to wait establishing a TCP connection (must be positive; `None` disables) |
152+
| `redis_socket_timeout` | `float \| None`| `2.0` | Seconds to wait on a read/write (must be positive; `None` disables) |
153+
| `redis_health_check_interval` | `int` | `30` | Seconds between pooled-connection health checks (`0` disables) |
154+
| `redis_max_connections` | `int \| None` | `None` | Connection-pool cap (`None` uses redis-py's default) |
155+
| `redis_retries` | `int` | `1` | Client-level retries with exponential backoff (`0` disables) |
156+
| `redis_fail_open` | `bool` | `False` | On Redis outage, skip the failing check instead of honoring `fail_secure` |
147157

148158
### Adapter Considerations
149159

0 commit comments

Comments
 (0)