Skip to content

Release Notes


v4.0.0 (2026-09-04)

Grammar-based secret redaction across log lines and events, expanded detection context coverage, dynamic rules persistence fixes, and thread-safe singleton state (v4.0.0)

Highlights

  • Breaking: require_headers() now enforces exact value matching. Setting a value other than "required" requires an exact match against the request header instead of being treated as a no-op; mismatches are rejected without echoing expected or received values. Audit existing require_headers() calls using non-"required" values.
  • Breaking: Rate-limit Redis key names now use hashed path segments. Endpoint segments in rate:{client_ip}:{endpoint_path} are sha256-hashed to prevent sensitive parameters from landing in Redis logs and monitors. Rate limit counters reset once on upgrade.
  • Breaking: excluded_detection_headers no longer fully silences a header. User-added headers inherit identity-header logic (skipping ssrf only for IP values) and are scanned by all other categories.
  • Grammar-based sensitive data redaction. Unified grammar-based redaction across log lines, pattern_detected threat events, exception logs, query strings, body fields, and hook payloads using configurable sensitive name sets.
  • Dynamic rules persistence fix. Resolved a LastKnownDynamicRules schema validation error in dump_last_known_rules_snapshot that prevented rule snapshot caching during SaaS outages (3.17.0 regression).
  • Singleton state isolation. Rates, security headers, and detection redactors now maintain per-middleware configuration contexts to prevent state bleed across multiple instances in a shared process.

Added

  • SecurityConfig.log_sensitive_headers: frozenset[str] (default frozenset()). Names headers to redact from guard logs and detection lines. Extends hardcoded _DEFAULT_SENSITIVE_LOG_HEADERS (authorization, proxy-authorization, cookie, x-api-key).
  • SecurityConfig.log_sensitive_params: frozenset[str] (default frozenset()). Names query parameters redacted from log URLs and detection lines. Extends _DEFAULT_SENSITIVE_LOG_FIELDS (access_token, refresh_token, api_key, apikey, token, password, secret, client_secret, signature).
  • SecurityConfig.log_sensitive_body_fields: frozenset[str] (default frozenset()). Names JSON keys, form fields, and multipart text parts replaced with [REDACTED] in field detection lines. Extends _DEFAULT_SENSITIVE_LOG_FIELDS.
  • _DEFAULT_SENSITIVE_LOG_HEADERS and _DEFAULT_SENSITIVE_LOG_FIELDS exported from guard_core.utils.
  • log_activity parameters. Gained sensitive_headers and sensitive_params parameters passed to internal log builders.

Fixed

  • Sensitive header, parameter, and body secret leaks. log_activity, _log_detected_component, and query URL parameters (?access_token=..., #token=..., https://user:PASS@host/) now mask sensitive values with [REDACTED] across all log lines, telemetry previews, and event payloads.
  • dump_last_known_rules_snapshot validation exception. Stripped unknown DynamicRules fields (emergency_whitelist_only, message) before snapshot validation, allowing local Redis/file fallback writes to succeed during backend outages.
  • Proxy identity header false positives. Added proxy identity headers (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) to the default excluded detection set to prevent auto-banning proxy IPs.
  • require_referrer() scheme parsing. Reduced scheme-prefixed entries (e.g., https://example.com:8443) to host and port before comparison.
  • SecurityConfig collection field revalidation. Revalidated 13 collection fields (exclude_paths, cors_allow_origins, custom_error_responses, etc.) on attribute reassignment and model_copy(update=...).
  • SQLi and category detection context gaps. Expanded SQLi, command injection, and traversal detection to header and url_path contexts. Kept noise-prone patterns (ORDER BY n bare) restricted to bodies/params.
  • Embedded JSON detection on form/multipart fields. Form and multipart fields now trigger embedded-JSON walks without bypassing redaction.
  • Uncalled SecurityDecorator event methods. Connected six orphaned event methods (send_access_denied_event, send_rate_limit_event, etc.) to pipeline checks.
  • Threat event pattern metadata. pattern_detected events now populate pattern_matched (redacted regex source), rule_type, decorator_type, and metadata.threat_categories.
  • Country whitelist loopback handling. Loopback addresses (127.0.0.1, ::1) and explicitly whitelisted IPs are now exempt from whitelist_countries check failures.
  • Custom pattern secret leaks. Custom regex pattern sources containing secret literals are now redacted across log lines, exception traces, anomaly events, and reporting outputs.
  • CSP report metadata leaks. CSP report URIs in source-file are now redacted, and line-number is coerced to an integer.
  • Multipart CRLF filename bypass. Headers on multipart parts beyond Content-Disposition are now scanned, preventing detection bypasses via inserted bare carriage returns.
  • Cross-middleware state bleed. Provided per-config state isolation for rate limiting, security headers, and detection redactors across multiple middleware instances in a single process.
  • Host logger handler removal. setup_custom_logging now preserves existing handlers attached to the guard_core logger by external applications.

Changed

  • SecurityConfig error formatting. Enabled hide_input_in_errors on SecurityConfig to prevent rejected secret inputs from echoing in validation error traces.
  • Linear redactor performance. Rewrote the pair-grammar redactor into a single linear pass (reducing 200 KB scan times from minutes down to <300ms).
  • Internal module split. Restructured body_content_scan.py and validator modules into dedicated sub-modules (body_json_scan.py, body_form_scan.py, embedded_json_scan.py) to maintain Rank-A maintainability scores.

Removed

  • Removed legacy shallow JSON functions _check_json_fields and _try_check_json_value in favor of recursive _check_embedded_json.

v3.17.0 (2026-09-01)

Last-known dynamic rules survive a restart during a SaaS outage (v3.17.0)

Added

  • Dynamic rules persist to a last-known local snapshot. When rules apply successfully, the exact payload that was applied is written to a local JSON snapshot, so a backend/Redis outage (Cloudflare-fronted SaaS unreachable, Redis down at process start) no longer leaves a restarted guard-core running with defaults. The write is atomic (tempfile + os.replace), so a crash mid-write can never leave a half-written snapshot. The snapshot stores a versioned strict envelope (LastKnownRulesSnapshot, schema_version + LastKnownDynamicRules): a snapshot written by a newer guard-core (unknown schema_version or unknown fields) and a malformed snapshot are both discarded with an error logged and the next store is tried; nothing is ever applied in degraded form.
  • Hydration on startup whenever dynamic rules are enabled. At initialize_agent, with enable_dynamic_rules=True, guard-core tries the last-known rules from Redis first and falls back to the file snapshot (including when the Redis payload is expired or malformed). Hydration runs once per process. A snapshot whose rules were already expired when stored is discarded with its own log and the next store is tried.
  • dynamic_rules_cache_path config field. Optional path for the file snapshot; unset disables the file layer entirely (Redis remains the last-known store). Validation enforces the type (non-empty string or Path); a path that is not writable at runtime is reported once per persist attempt and skipped, never a request failure.

Changed

  • Redis is the primary last-known store, the file is the opt-in fallback. With Redis configured, persistence goes to Redis and the file snapshot is a second line of defense read only when Redis has nothing usable. Without Redis, the file snapshot (when configured) is the only store.
  • _has_rule_expired no longer doubles as the expired-rule warning dedup. Expiry checking and the once-per-rule warning are separated, so a hydration pass that skips expired rules cannot suppress or duplicate the warning state of the live apply path.

v3.16.0 (2026-09-01)

on_block hook plus TTL check-then-use closure on the ban path (v3.16.0)

Added

  • A new optional SecurityConfig.on_block callback fires exactly once per blocked request. on_block receives (request, payload) at the block decision in SecurityCheckPipeline, with a payload carrying check_name, reason, trigger_info, passive_mode, client_ip, path, method and status_code, so an application can observe what guard-core decided in-process instead of reconstructing it from logs. It is deliberately not fired for custom_request_check or route custom_validators (application-authored, the app already knows), the HTTPS-enforcement redirect (a redirect is not a block), or an adapter's Redis-unavailable response (cover that with on_error). In passive mode it fires at flag time with status_code=None, since no response is ever sent. In ASGI deployments the hook may be sync or async; in WSGI deployments it must be sync, and an async hook raises TypeError with an explicit message. A hook that raises is caught and logged, never propagated into request handling. Both fire sites, the block decision and the passive-mode flag inside log_activity, share one payload builder in guard_core/_utils/block_events.py, with a hand-maintained sync twin enforcing the WSGI contract (#87).

Fixed

  • A TTLCache check-then-use race in SecurityHeadersManager.get_headers raised KeyError under a TTL boundary crossing. The header cache decided presence with a membership test and read the value as a separate operation; cachetools reads its clock once per operation, so an expiry crossing between the two made the subscript raise, escaping get_headers into the pipeline's generic handler (a 500 with fail_secure=True, a silently skipped check with fail_secure=False). The read is now a single .get() whose frozen clock makes presence and value agree by construction; the regression test drives the exact expiry boundary and asserts the returned headers ARE the cached object, so a silent rebuild cannot masquerade as passing (#86).
  • The same race survived on the ban path, where a KeyError failed the ban check open. is_ip_banned and unban_ip decided against the banned_ips TTLCache with separate clock reads, and cachetools' __delitem__ itself raises when the entry is expired at the moment of the delete, so the ban purge could raise with no interleaving at all. The pipeline's generic catch turned that into a 500 with fail_secure=True and, with the default fail_secure=False, a request whose ban check silently did not run. Both now decide with a single banned_ips.get(ip) read and purge best-effort, preserving the shipped behavior exactly (cachetools' own __contains__ already expiry-checks, so an entry expired at read time has always fallen through to the network and Redis lookups). Regression tests drive the exact expiry boundary and fail on the parent implementation.
  • The path-exclusion and body-unavailable dedup caches read the same way. Neither could crash, since they only check then set, but both decided have-I-already-fired with two clock reads; both now read once with .get(), closing the race class everywhere it appeared.

v3.15.1 (2026-08-31)

@bypass decorator now filters unknown check names instead of silently enforcing them (v3.15.1)

Fixed

  • @bypass decorator filters unknown check names. Previously @bypass(["rate_limit", "geo_check"]) silently stored "geo_check" on the route config's bypassed_checks, where it had no effect: should_bypass_check only ever tests "all", "ip_ban", "ip", "clouds", "rate_limit", and "penetration", so a mistyped or invalid check name left the intended check fully enforced with no diagnostic. bypass() now filters through a new VALID_BYPASS_CHECKS frozenset, the same way @block_clouds filters through VALID_CLOUD_PROVIDERS (added in v3.1.0), and warns on ignored entries. Async and sync mirrors updated. This also means the security_bypass middleware event's bypassed_checks payload now reports only recognized tokens, so a caller who previously passed extra labels for bookkeeping through bypass() will no longer see them there.

Added

  • VALID_BYPASS_CHECKS frozenset exported from guard_core.models, alongside VALID_CLOUD_PROVIDERS. Holds the six tokens should_bypass_check recognizes: "all", "ip_ban", "ip", "clouds", "rate_limit", "penetration".

v3.15.0 (2026-08-27)

Post-3.14.0 follow-ups: redis_fail_open honored by the rate limiter, a depth-bounded JSON body walk and per-request scan-char budget, corrected X-Forwarded-For depth over-counting and oversized-body scanning, no more double-logging into host handlers, and a rank-A internal module split (v3.15.0)

Highlights

  • Breaking: the rate limiter now honors redis_fail_open on a Redis failure instead of always falling back to the in-memory window. redis_fail_open=True still falls back to the in-memory window but now logs a WARNING once per process instead of an ERROR on every request; redis_fail_open=False (the default) now raises GuardRedisError, letting the pipeline apply fail_secure exactly as for any other check's Redis failure instead of silently degrading a shared rate limit to a per-process one.
  • Bounded JSON body walk and per-request scan-char budget. A new SecurityConfig.detection_max_json_depth (default 32) rewrites the JSON body walk as a depth-bounded, recursion-free iteration instead of one Python call per nesting level, and a subtree reached at that depth is serialized back to text and scanned as one value (GHSA-f6cf-jjhc-qp85); a new SecurityConfig.detection_max_scan_chars (default 65536) separately bounds the total characters handed to the pattern engine per request (GHSA-3hfx-8m47-5f9h residual).
  • X-Forwarded-For over-count correction. A declared trusted_proxy_depth that over-counts the real proxy hops let a client rotate its resolved identity freely; guard-core now verifies every entry to the right of the depth-selected one is itself a listed trusted proxy and, when it is not, resolves identity by walking the chain right to left instead, warning once per process (GHSA-8xvm-856x-7hwp claim 1 residual).
  • Oversized-body scanning. A body whose declared Content-Length exceeds detection_max_body_inspect_bytes is no longer silently skipped when the adapter supports a bounded read; the first detection_max_body_inspect_bytes bytes are read through it and scanned instead, keeping the existing single-memory-bound guarantee (GHSA-3hfx-8m47-5f9h residual).
  • Auto-configuring detection singleton. detect_penetration_attempt(request, config) now configures the detection singleton from config itself whenever it is unconfigured or was last configured from a different config object, so a direct caller (guard-core-mcp's check_payload, a PoC script, a test written against this function directly) runs the same enhanced detection path an adapter runs instead of the slower legacy fallback.
  • No more double-logging into host handlers. setup_custom_logging's console handler now carries a filter that checks the host's root logger handlers at the moment each record is emitted, yielding to them whenever they exist instead of printing every guard security event twice when a host has already configured its own root logging.
  • Internal module split, rank A across the board. suspatterns_handler.py, handler_initializer.py, _security_config_validators.py and _redos_structure.py are restructured into cohesive private sibling modules, with every previously importable name re-exported from its original module path; detection results, sync mirrors, and behavior are unchanged.
  • Bug-hunt fixes. A signature straddling the oversized-body scan boundary is now caught the same way whether or not the client declared an oversized Content-Length; the sync BehaviorTracker stores are now thread-locked, closing a RuntimeError under a real WSGI thread pool; an X-Forwarded-For entry carrying a port (1.2.3.4:5678, [2001:db8::1]:5678) is now parsed instead of discarding the whole header.

Added

  • SecurityConfig now warns at construction when whitelist contains a /0 network. A whitelist entry of 0.0.0.0/0 or ::/0 makes every address whitelisted, so blacklist, blocked_countries and IP bans can never block anyone; this was previously silent. Precedence and every access decision are unchanged, a /0 whitelist still allows everyone, this is a signal only (#79).

Changed

  • PerformanceMonitor.record_metric no longer recomputes its running average with statistics.mean's exact-Fraction arithmetic. Profiling the detection_max_scan_chars budget's worst case (28 values x 9342 characters) found this call, made once per pattern check (about 8600 times for that request), accounted for roughly 30% of enhanced-mode detection CPU; stats.avg_execution_time is now math.fsum(stats.recent_times) / len(stats.recent_times), the same approach monitor_anomalies.py already used elsewhere, correct to within ordinary floating-point rounding. First of three constant-factor cuts named in the profile report for GHSA-3hfx-8m47-5f9h; none change any detection decision.
  • SemanticAnalyzer.analyze no longer tokenizes its input twice. analyze_attack_probability and the token_count field both called extract_tokens independently on the same content; analyze now extracts tokens once and reuses them for both. Second of the three constant-factor cuts.
  • The url-decoded detection view no longer re-runs percent/unicode decoding from scratch. _check_url_decoded_view_patterns called preprocess_url_decoded_newline_preserving, which independently repeated decode_common_encodings on the raw content even when the main preprocessing pass had already decoded it, or found nothing to decode; it now reuses the main pass's decoded intermediate (ContentPreprocessor.preprocess_with_decoded) instead of decoding twice. Third of the three constant-factor cuts.
  • Breaking: the rate limiter now honors redis_fail_open on a Redis failure instead of always falling back to the in-memory window. _redis_request_count (check_rate_limit and check_rate_limit_by_ip, guard_core/handlers/ratelimit_handler.py) previously caught every Redis error itself and silently used the in-memory window regardless of redis_fail_open, so a Redis outage silently turned a shared, cross-worker rate limit into a per-process one (N workers effectively multiplying rate_limit by N) with no way to opt out. redis_fail_open=True still falls back to the in-memory window, but now logs a WARNING once per process ("Redis unavailable for rate limiting...") instead of an ERROR on every request. redis_fail_open=False (the default) now raises GuardRedisError instead of silently falling back: check_rate_limit lets SecurityCheckPipeline._handle_check_error apply fail_secure exactly as for any other check's Redis failure (500 under fail_secure=True, pass-through otherwise), and check_rate_limit_by_ip lets the exception reach its own caller. A deployment relying on the previous always-fall-back behavior with the default redis_fail_open=False now needs to set redis_fail_open=True explicitly to keep it. NoScriptError reload handling and the auto-ban feed are unchanged.
  • Internal module split, no public name changes. guard_core/handlers/suspatterns_handler.py, guard_core/core/initialization/handler_initializer.py, guard_core/_security_config_validators.py and guard_core/detection_engine/_redos_structure.py were each at maintainability rank below A; every one is restructured into cohesive private sibling modules (guard_core/handlers/_suspatterns_*.py, guard_core/detection_engine/_redos_structure_*.py, and similarly for the other two), with every previously importable name re-exported from its original module path. Detection results, sync mirrors, and behavior are unchanged.

Fixed

  • A signature straddling the detection_max_body_inspect_bytes cap boundary was missed when the client declared an oversized Content-Length, but caught when it did not. _read_capped_body_prefix (no declared Content-Length, or one at or under the cap) fetched max_bytes plus a small overlap (the longest compiled pattern length, up to 256 bytes) so a signature split across the cap boundary was still scanned in full; _read_oversized_declared_body (a declared Content-Length over the cap, added in the previous fix wave) read exactly max_bytes with no overlap, so the identical body missed detection on that path alone. Both paths now compute the fetch size through one shared helper, _capped_body_fetch_size. Sync mirror regenerated.
  • CloudManager.refresh_async and _refresh_providers_via_redis_handler no longer apply freshly fetched cloud IP ranges to the in-memory cache before the store write that persists them. A store or Redis write failure was caught and logged, but the in-memory ip_ranges had already been updated, so is_cloud_ip reported ranges that were never persisted, a silent divergence between what a single process believes and what other workers see through Redis. The write now happens first; on failure the provider falls through to the existing preserve-or-empty fallback, the same one already used for a fetch failure. Sync mirror regenerated.
  • BehaviorTracker.usage_counts/return_patterns outer stores, keyed by endpoint id, are now bounded. Only the inner per-client store was LRU-bounded; the outer dict grew one entry per distinct endpoint id forever. It now evicts the least-recently-touched endpoint once it tracks 10,000, the same _lru_pop_or_create pattern already used for the inner store, dropping that endpoint's entire inner store on eviction.
  • The sync BehaviorTracker.usage_counts/return_patterns in-memory stores had no lock, unlike their async counterparts (which never need one). get_recent_event_count iterated usage_counts.values() while track_endpoint_usage/track_return_pattern inserted or LRU-evicted entries from another thread with no synchronisation; under a real WSGI thread pool this raised RuntimeError: dictionary changed size during iteration (measured: 55 times across 6 writer and 2 reader threads in 3 seconds), silently dropped by the enricher's blanket exception handler. BehaviorTracker.__init__ now builds a threading.Lock in the async source (a pattern already used elsewhere, e.g. core/checks/helpers.py's _suspicious_counts_lock) that guards every read and write of both stores; it carries through the sync mirror unchanged since a plain threading.Lock needs no async-to-sync translation, and costs one uncontended acquire per call in the async tree. ratelimit_handler.py's sync-only _redis_fail_open_warned flag now flips under its existing _by_ip_lock too, so the warn-once guarantee holds under threads.
  • An X-Forwarded-For entry carrying a port was discarded, falling back to the connecting peer for the whole header. 1.2.3.4:5678 and [2001:db8::1]:5678 failed ip_address() parsing outright, since neither _strip_ip_brackets nor anything downstream stripped the port. Each comma-separated entry now has a trailing port stripped before parsing (_strip_forwarded_entry_port, guard_core/_utils/ip_extraction.py): a bracketed [v6]:port shape or an entry with exactly one colon has the port removed; a bare (unbracketed) IPv6 address, which always carries two or more colons, is never mistaken for host:port and is left unchanged, as is an entry that looks like host:port but has a non-numeric or missing port, which still fails parsing through the existing malformed-entry path. Sync mirror regenerated.
  • guard-core no longer double-logs into a host's own root logger handlers. setup_custom_logging unconditionally attached a console StreamHandler to the guard_core logger and left propagate=True; a host that had already configured its own root logger handlers (the common logging.basicConfig() case) printed every guard security event twice, once formatted by guard's own handler and once via propagation through the host's. A setup-time check alone is not enough, since the ordering can go the other way: guard configures itself first while the root logger is still empty, and the host configures its own root handlers afterward, which reintroduces the same duplicate under the opposite ordering. setup_custom_logging now always attaches its console handler, but that handler carries a logging.Filter that checks logging.getLogger().handlers at the moment each record is emitted rather than at setup time: guard's console output yields to the host's root handlers whenever they exist, whichever was configured first, and the event propagates to the host's own handlers exactly once. The custom_log_file handler carries no such filter and is attached in every case, unaffected, and level handling is unchanged. A host that wants guard's own formatted output (text or JSON) while running its own root handlers should set custom_log_file, or attach its own handler directly to the guard_core logger.

Security

  • Uncontrolled recursion in the JSON body walk (CWE-674). The structural JSON-body scan recursed one Python call per nesting level with no depth bound; a {"a":{"a":...}} body nested past Python's recursion limit raised RecursionError out of detect_penetration_attempt, and just below that limit the enhanced-detection exception fallback silently swallowed the error and scanned the request as clean instead of failing secure. The walk is now an explicit-stack iteration with no recursion anywhere in guard-core's own JSON traversal. A new SecurityConfig.detection_max_json_depth (default 32, 1 to 1000) bounds the nesting depth walked structurally; a dict or list reached at that depth is serialized back to text and scanned as one value instead, bounded by detection_max_content_length, and a one-time warning names the client IP. _check_value_enhanced no longer treats RecursionError as a fallback case, it now re-raises into the pipeline's fail-secure handling instead of ever returning a silent clean verdict (GHSA-f6cf-jjhc-qp85).
  • Uncontrolled recursion in embedded-JSON detection for query, header and form values (CWE-674, same class as GHSA-f6cf-jjhc-qp85, different context). _try_check_json_value (the check that looks for a JSON object embedded in a single query parameter, header, or form-field value) only caught json.JSONDecodeError around json.loads; a value holding around 1000 nested braces raised RecursionError instead, out of detect_penetration_attempt, before the surrounding fallback logic ever ran. RecursionError is now caught in the same place and treated as "not embedded JSON": the value falls through to the ordinary pattern scan of its raw text instead of the structural walk, and the existing detection_max_json_depth warning fires once per request.
  • A declared trusted_proxy_depth that over-counts the real proxy hops let a client rotate its resolved identity freely (GHSA-8xvm-856x-7hwp, claim 1 residual). With trusted_proxies non-empty, extract_client_ip selected ips[-trusted_proxy_depth] from X-Forwarded-For with no check that the entries to its right were actually appended by trusted proxies; when the real chain had fewer hops than the declared depth, a client that supplied extra entries controlled which one landed at that position, and the 3.14.0 chain-warnings did not catch it because the selected entry is an ordinary public address, not itself a listed trusted proxy. guard-core now verifies that every entry to the right of the depth-selected one is itself a listed trusted proxy (canonicalised, so an IPv4-mapped or bracketed IPv6 spelling is recognised); if any is not, it logs one warning per process naming trusted_proxy_depth, the count of untrusted entries, and the fix, and resolves the identity by walking the chain right to left, returning the first entry that is not a listed trusted proxy (the address the nearest genuine proxy actually appended). Nothing changes when trusted_proxies is empty, when the chain is shorter than the declared depth, or when every entry to the right of the selection is already a listed trusted proxy.
  • A body whose declared Content-Length exceeds detection_max_body_inspect_bytes is no longer silently skipped when the adapter supports a bounded read (GHSA-3hfx-8m47-5f9h residual). _read_capped_body returned None for the whole request body once the declared length exceeded the cap, so an attacker could evade body-based detection entirely just by declaring an oversized Content-Length, even with an attack in the first few bytes. When the request implements the bounded reader (read_body_prefix), it now reads only the first detection_max_body_inspect_bytes bytes through it and scans that prefix, keeping the existing single-memory-bound guarantee (a JSON body that gets cut mid-structure falls back to the existing blob-text scan); when the request does not implement a bounded reader, the body is not read at all, the same skip as before this change, since reading it any other way would mean buffering the whole oversized body first. Either way a one-time warning names the cap, the client, and which of the two happened. A new SecurityConfig.detection_max_scan_chars (default 65536, 1024 to 262144) separately bounds the total characters handed to the pattern engine per request, at the same accounting point detection_max_scan_values uses; a handful of individually large values could previously cost as much CPU as many small ones without ever approaching the value-count cap (28 values x 9342 characters cost 1.81s CPU in enhanced mode). The budget is a leaky bucket, not a per-value truncation: a value already in progress when checked is always scanned in full even if that pushes the running total over the cap, so a single large legitimate value is never silently dropped; only a value that would start after the budget is already spent is skipped, with the same one-time warning. detect_penetration_attempt(request, config) also now configures the detection singleton from config itself whenever it is unconfigured or was last configured from a different config object (compared by identity, so this is a no-op once already configured from the same object), instead of requiring the caller to call sus_patterns_handler.configure(config) first: a direct caller that never did (guard-core-mcp's check_payload, a PoC script, a test written against this function directly) previously ran the legacy per-pattern thread-pool dispatch, an order of magnitude slower than the enhanced path and, on some payloads, producing different detection results.

v3.14.0 (2026-08-26)

Post-3.13.0 hardening: identity and proxy-trust warnings, bounded in-memory stores, resilient Redis/GeoIP/Azure startup, telemetry secret redaction, and detection scan-cap and SSRF hardening (v3.14.0)

Highlights

  • Breaking: PerformanceMonitor.get_summary_stats/get_slow_patterns/get_problematic_patterns are now coroutines, and detection_max_scan_values has a floor. All three reporting methods are async def in the async tree and must now be awaited; the guard_core.sync mirror keeps them synchronous. SecurityConfig.detection_max_scan_values now requires at least 2 (each named value costs two scan units, so 1 could never scan a value) (GHSA-3hfx-8m47-5f9h).
  • Missing-client requests rejected; new unix trusted-proxy token. A request with no client address (request.client_host is None) is now rejected instead of skipping the entire security pipeline; a new "unix" token in trusted_proxies resolves X-Forwarded-For behind Unix-socket deployments (GHSA-634g-4wr8-xwxv).
  • X-Forwarded-For chain warnings. guard-core now warns once when a forwarded-for chain cannot satisfy trusted_proxy_depth, and once when the depth-selected entry is itself a listed trusted proxy; resolved identity is unchanged in both cases (GHSA-8xvm-856x-7hwp).
  • Prefix-0 trusted-proxy and empty-detection-category startup warnings. SecurityConfig now warns at construction on a /0 trusted-proxy network (0.0.0.0/0, ::/0) and on an empty enabled_detection_categories with detection enabled, two previously silent misconfigurations (#79).
  • Ban canonicalisation. ban_ip, is_ip_banned, and unban_ip canonicalise the address before storing, querying, or deleting it, closing a silent no-op when the same IP is banned and unbanned under different spellings (#81).
  • Redis-outage startup degrade. A Redis outage at startup no longer crashes the app; redis_fail_open=True degrades to in-memory backends, redis_fail_open=False re-raises so the adapter returns a clean error (#76).
  • GeoIP last-known-good. IPInfoManager keeps the last known good GeoIP database on a failed refresh or download instead of deleting it, and downloads to a temporary file that swaps in atomically (#78).
  • AzureCloud service tag. Azure cloud-IP fetch now loads the AzureCloud service tag by name instead of whichever tag sorts first (#77).
  • Rate-limit stores bounded; Retry-After on 429s. In-memory rate-limit stores are now LRU-bounded at 10,000 IPs; every 429 response now carries Retry-After (GHSA-g53w-gmp9-9ch3, #81).
  • Behavior-tracker stores bounded. BehaviorTracker.usage_counts/return_patterns per-client stores are now LRU-bounded at 10,000 clients per key, the same pattern applied to the rate-limit stores above (GHSA-g53w-gmp9-9ch3).
  • Float anomaly statistics. PerformanceMonitor's statistical-anomaly check replaces exact-Fraction arithmetic with math.fsum-based float mean/variance, removing an unbounded per-value CPU tax (GHSA-grqq-qh92-hw79).
  • Per-request scan cap. SecurityConfig.detection_max_scan_values bounds the number of request values scanned per request, replacing an unbounded scan an attacker could exhaust with padding (GHSA-3hfx-8m47-5f9h).
  • Telemetry secret redaction. The IPInfo token and Redis password no longer reach telemetry: the token moves to an Authorization header, and RedisManager.initialize strips userinfo from redis_url before it reaches agent events (#80).
  • Header-name validation. Custom security-header names, including Redis-loaded ones, are now validated against RFC 9110 token grammar and rejected on CRLF or other non-token characters (#81).
  • SSRF form coverage. ssrf now detects the IPv4-mapped IPv6 loopback bracket form and a trailing-dot localhost (#81).

Added

  • New SecurityConfig.detection_max_scan_values bounds the number of request values scanned per request, including JSON embedded within a single query-parameter, header, or body value. Default 512. Once reached, remaining query-parameter, header, and body values are not scanned, and a one-time logger.warning names the client IP, replacing an unbounded per-request scan an attacker could exhaust with padding (GHSA-3hfx-8m47-5f9h).

Changed

  • PerformanceMonitor.get_summary_stats, get_slow_patterns, and get_problematic_patterns are now coroutines in the async tree. They snapshot recent_metrics/pattern_stats under the monitor's lock before reading, the same fix applied to record_metric's statistical-anomaly check; a direct caller must now await them. SusPatternsManager.get_performance_stats, the only production caller, was already async and its own call signature is unchanged. The guard_core.sync mirror keeps all three as plain methods.

Fixed

  • A request with no client address (request.client_host is None, e.g. behind a Unix domain socket or a misbehaving ASGI adapter) is now rejected instead of skipping the entire security pipeline. fail_secure=True (the default) returns 403 with a one-time warning naming the cause and the fix; fail_secure=False runs the pipeline with identity "unknown", the same fallback already used elsewhere, with the same one-time warning. Excluded paths (health and readiness endpoints) are unaffected: they pass through before identity is resolved, exactly as before, so a Unix-socket deployment upgrading without "unix" in trusted_proxies does not start failing orchestrator probes. A new "unix" token in trusted_proxies marks a peer-less connection as a trusted hop, so X-Forwarded-For still resolves the real client on Unix-socket deployments. Under fail_secure=False, check_ip_access now treats the "unknown" identity as no address available: the request is allowed unless a whitelist or a country allow-list is configured (whitelist or whitelist_countries), and the blacklist, blocked_countries, and cloud-provider checks are skipped since none can match without an address; check_route_ip_access applies the same rule to a decorated route's ip_whitelist/whitelist_countries, so a route with any other restriction (or none at all) no longer 403s an "unknown" identity outright (GHSA-634g-4wr8-xwxv).
  • guard-core now warns once when an X-Forwarded-For chain cannot satisfy trusted_proxy_depth, and once when the depth-selected entry is itself a listed trusted proxy. Both are misconfiguration signals; resolved identity is unchanged in both cases (a chain shorter than the depth still falls back to the connecting peer, exactly as before). trusted_proxy_depth semantics are unchanged: it is not reinterpreted as a ceiling (GHSA-8xvm-856x-7hwp).
  • SecurityConfig now warns at construction on two silent-gap configurations. A trusted_proxies entry that is a /0 network (0.0.0.0/0, ::/0) trusts every peer to set X-Forwarded-For; an empty enabled_detection_categories with enable_penetration_detection=True runs detection that can never match anything. Both previously succeeded with no signal; both now log a WARNING and still succeed (#79).
  • ban_ip, is_ip_banned and unban_ip now canonicalise the address before storing, querying or deleting it. 2001:DB8::1, 2001:db8::1 and the fully expanded form (and IPv4-mapped IPv6 like ::ffff:1.2.3.4 vs 1.2.3.4) were previously three different keys on both the in-memory cache and Redis, so unban_ip called with a different spelling than the one used to ban was a silent no-op. CIDR entries are unaffected (_canonicalize_ip returns non-address strings unchanged) (#81).
  • HandlerInitializer.initialize_redis_handlers no longer lets a Redis outage at startup crash the app. GuardRedisError from RedisManager.initialize() is now caught and logged; redis_fail_open=True degrades to the in-memory backends used when Redis is disabled instead of wiring ip_ban_manager, the rate limiter, and sus_patterns_handler to Redis, and redis_fail_open=False (default) re-raises so the adapter returns a clean error instead of an unhandled exception. sus_patterns_handler.initialize_redis and IPInfoManager.initialize/_download_database now also catch a Redis failure on their own eager cache reads and writes, logging a warning and keeping the handler wired to Redis for later retries instead of letting the exception escape past the fail-open degrade (#76).
  • IPInfoManager keeps the last known good GeoIP database on a failed refresh or download. initialize() and refresh() no longer delete the on-disk .mmdb file or clear reader when a download fails; maxminddb.open_database is now guarded at every call site, and only an unreadable (corrupt) file is removed. refresh() no longer clears reader before attempting the download, and only swaps in the new reader once the fresh database opens successfully (#78).
  • Azure cloud-IP fetch now loads the AzureCloud service tag instead of whichever tag sorts first. The Microsoft ServiceTags document lists many tags per download; fetch_azure_ip_ranges previously took values[0], which is not guaranteed to be AzureCloud. It now selects the entry named AzureCloud and logs an error and returns an empty set if that tag is missing (#77).
  • In-memory rate-limit stores are now bounded, and 429 responses carry Retry-After. _by_ip_request_timestamps, _by_ip_autoban_counts, and RateLimitManager.request_timestamps grew one entry per distinct IP forever; all three now evict the least-recently-touched key once they track 10,000 IPs, the same LRU-bound pattern _increment_suspicious_counts already uses. _handle_rate_limit_exceeded now sets Retry-After to the effective rate-limit window on every 429 it returns, in-memory and Redis-backed alike. Sync mirror hand-updated (GHSA-g53w-gmp9-9ch3, #81).
  • BehaviorTracker.usage_counts/return_patterns per-client in-memory stores are now bounded. Each outer key (endpoint id, or endpoint:pattern for return-pattern rules) now evicts its least-recently-touched client IP once it tracks 10,000 clients, the same LRU-bound pattern applied to the rate-limit stores above. The Redis-backed path and get_recent_event_count are unchanged (GHSA-g53w-gmp9-9ch3).
  • PerformanceMonitor's statistical-anomaly check no longer uses exact-Fraction arithmetic. detect_statistical_anomaly called statistics.mean/statistics.stdev on every record_metric once a pattern's window warmed to min_samples_for_anomaly, an unbounded per-value CPU tax on all enhanced-mode traffic (CWE-400); replaced with math.fsum-based float mean/sample-variance, producing the same anomaly decisions. Sync mirror regenerated.
  • IPInfo token and Redis password no longer reach telemetry. IPInfoManager._download_database sends the IPInfo token as an Authorization: Bearer header instead of a ?token= query-string parameter, and _send_geo_event's reason no longer embeds a raw exception's str() (which could carry the request URL via aiohttp.ClientResponseError/requests.HTTPError); reduced to the exception class name plus HTTP status when available. RedisManager.initialize strips userinfo from redis_url before it reaches the redis_connection/redis_error agent events, keeping only scheme://host[:port]. Async and sync mirrors updated identically (issue #80).
  • Custom security-header names are now validated, including Redis-loaded ones. SecurityHeadersConfigMixin._validate_header_name enforces the RFC 9110 token grammar and rejects a name containing CRLF or any other non-token character, wired into _add_custom_headers. SecurityHeadersCacheMixin._load_cached_config now runs every cached custom_headers name and value through the same two validators and discards the entry (logs a warning, keeps the prior configuration) instead of applying an invalid cached header. Async and sync mirrors updated identically (issue #81).
  • ssrf detects the IPv4-mapped IPv6 loopback bracket form and a trailing-dot localhost. http://[::ffff:127.0.0.1]/ and http://localhost./ now match the ssrf category. http://[::ffff:8.8.8.8]/ (a public address in the same bracket shape) and the existing localhost.example.com/notlocalhost.io lookalikes stay unflagged. Sync mirror updated in lockstep (issue #81).
  • guard_core.handlers.behavior_handler and guard_core.sync.handlers.behavior_handler can now be imported standalone. The behavior-tracker bound-store fix above pulled _lru_pop_or_create in from guard_core.core.checks.helpers, which imports guard_core.core, whose package init reaches back through guard_core.decorators.behavioral to BehaviorRule in the still-initializing behavior_handler module; a fresh interpreter raised ImportError: cannot import name 'BehaviorRule' from partially initialized module 'guard_core.handlers.behavior_handler' (most likely due to a circular import), breaking import guard in fastapi-guard. _lru_pop_or_create now lives in a dependency-free leaf module, guard_core._utils.lru_store; helpers.py re-exports it and behavior_handler.py/ratelimit_handler.py import it directly, so neither pulls in guard_core.core at module load. Sync mirror regenerated; the hand-maintained guard_core/sync/handlers/ratelimit_handler.py updated to match.
  • RedisManager.initialize no longer abandons a live client when re-initialising. Every re-initialised manager (each middleware built in tests, any app that constructs the manager twice) previously overwrote self._redis without closing the client already there, leaking a redis-py connection pool that GC later finalised with ResourceWarning: unclosed Connection; the enable_redis=False branch and the connection-failure branch dropped their client the same way. initialize and close now route through a shared _discard_client that closes the existing client before it is replaced or dropped, so the manager owns at most one live client at a time; a close failure is logged and swallowed rather than blocking re-initialisation. Sync mirror regenerated.
  • PerformanceMonitor.record_metric no longer runs its statistical-anomaly check outside the lock. _check_anomalies iterated a pattern's recent_times deque (math.fsum plus a generator) after the lock guarding appends to that same deque had already been released; a concurrent record_metric call under real OS threads (the sync tree, e.g. gunicorn --threads) could append mid-iteration and raise RuntimeError: deque mutated during iteration, dropping detection to the fallback scan. The statistical-anomaly check now runs inside the same lock as the append that feeds it; anomaly emission (agent send, cooldown) stays outside the lock. Sync mirror regenerated (PR #82).
  • The enhanced-detection exception fallback no longer runs an unbounded regex scan. _fallback_pattern_check called pattern.search() directly on every compiled pattern with no scan-window bound and no timeout, so any exception in the enhanced path (including the PerformanceMonitor race above) silently re-exposed the quadratic built-in patterns GHSA-r7hm-rjvg-xx7j bounded. The fallback now dispatches each pattern through SusPatternsManager._check_regex_pattern, the same bounded matchers (scan-window finders and the executor-timeout path) the enhanced scan uses, so a RecursionError on one pattern is still logged and skipped but every scan stays bounded. Async and sync mirrors updated identically (GHSA-r7hm-rjvg-xx7j).
  • PerformanceMonitor.get_summary_stats/get_slow_patterns/get_problematic_patterns no longer iterate recent_metrics/pattern_stats outside the lock. get_summary_stats iterated the recent_metrics deque three times, and get_slow_patterns/get_problematic_patterns iterated the pattern_stats dict, both unlocked while record_metric appends to recent_metrics and inserts/evicts pattern_stats keys under the lock; reachable through the public SusPatternsManager.get_performance_stats(), this raised the same RuntimeError: deque mutated during iteration class as the record_metric race above (and, for pattern_stats, RuntimeError: dictionary changed size during iteration). All three now snapshot the collection they read under self._lock before handing it to the (unlocked, pure) reporting helpers; get_pattern_report was audited and left unlocked, since it only does a single dict lookup plus scalar reads, never an iteration. Async and sync mirrors updated identically.

v3.13.0 (2026-08-24)

Detection-engine hardening: ReDoS validation backstop, scan-window mechanism, all-category ingestion bypasses, new deserialization category, and breaking auth-verifier requirement (v3.13.0)

Highlights

  • Breaking: require_auth/api_key_auth now require a verifier. Supply one per route (verifier=) or globally (SecurityConfig.auth_verifier), or 401 fail-closed (GHSA-x96c-fcg2-x2f9, CWE-287).
  • Breaking: 15 dynamic-rule snapshot fields validate on assignment. auto_ban_threshold, blocked_countries, block_cloud_providers, blocked_user_agents, etc. now raise ValueError on out-of-range or wrong-type instead of silently landing in the running config.
  • ReDoS backstop ships. validate_pattern_safety runs in a killable subprocess and flags adjacent broad unbounded quantifiers, ambiguous optional tails, unreachable terminator scans, and ambiguous literal boundaries; 35 known-quadratic built-ins are rejected pending pattern rewrites. The shared executor timeout is GIL-bound and does not bound a catastrophic match; a 512-byte User-Agent cap is the real bound (GHSA-r7hm-rjvg-xx7j, also closing CVE-2025-53539 and CVE-2025-54365).
  • New scan_window mechanism. bounded_search/bounded_finditer bound the regex search window (not the match length) for PREFIX[^x]*TERMINATOR shapes, with no length cap and no N+1 bypass.
  • New deserialization category (CWE-502). Java/pickle/PHP/.NET/Ruby serialized payloads in base64 wire form.
  • All-category ingestion bypasses closed. Non-UTF-8 body scan (CWE-693, every released version), preprocessor truncation gaps, _BASE64_RE floor lowered 20 to 12, parameter names now scanned, structured-JSON operator keys, and a surrogateescape decode boundary across five sites.
  • Detection widenings. Nine categories extended past literal spellings; seven widened to new contexts (header, url_path, query_param); new nosql [$op], proto_pollution, template {{}}/#{}, sqli tautology, and cmd_injection bare $(...)/Log4Shell JNDI patterns.
  • Telemetry hygiene. OtelHandler and LogfireHandler no longer clobber or destroy host providers; ban_ip refuses loopback and trusted-proxy targets.

Breaking

  • require_auth and api_key_auth require a verifier. Supply one per route via verifier= or globally via SecurityConfig.auth_verifier. Without a resolvable verifier, requests are rejected with 401 (fail-closed). Previously any Bearer/Basic prefix or any API-key header value was accepted without validation (GHSA-x96c-fcg2-x2f9, CWE-287). The old header-presence/scheme-prefix behavior is no longer the default; use require_authorization_header(scheme=...) for a presence-only gate, documented as NOT authentication.
  • Post-construction assignment to 15 dynamic-rule snapshot fields now revalidates. 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, emergency_mode, emergency_whitelist, auto_ban_threshold, auto_ban_duration, enable_rate_limit_auto_ban raise ValueError on a wrong-type or out-of-range value where a bare setattr previously accepted it silently. DynamicRuleManager._apply_rules's rollback restores every already-mutated field on a later validation failure, so a push stays atomic.
  • SecurityConfig.auto_ban_threshold and auto_ban_duration now enforce ge=1. Construction with 0 or a negative value now raises; pass 1 or above.
  • block_cloud_providers accepts six providers and rejects unknown names. DigitalOcean, Linode, and Vultr join AWS/GCP/Azure. An unrecognized provider name (the part before an optional :!region suffix) now raises ValueError at construction and reassignment instead of being silently dropped. The argless @block_clouds() decorator now blocks all six providers; pass an explicit list to keep the previous three-provider scope.
  • block_cloud_providers=None stays None. Construction, assignment, model_copy, and dynamic-rule rollback/restore no longer silently drift None to frozenset(); every real consumer already gates on truthiness.

Added

  • is_ip_allowed and check_ip_access are now exported at the top level as guard_core.is_ip_allowed and guard_core.check_ip_access, for websocket and framework-agnostic callers that have only a raw IP. Async and sync mirrors updated identically.
  • check_rate_limit_by_ip(ip, config, redis_handler=None, endpoint_path="") (guard_core.check_rate_limit_by_ip): a request-free rate-limit primitive for websocket consumers. Returns True/False and records a hit per call. With the default endpoint_path="" the Redis key collapses to the HTTP pipeline's global bucket for that IP; pass a non-empty endpoint_path for an isolated budget. Feeds the auto-ban engine when enable_rate_limit_auto_ban and enable_ip_banning are both set. Async and sync mirrors updated identically.
  • SecurityConfig.enable_rate_limit_auto_ban (bool, default False): opt-in wiring that feeds rate-limit violations into the same auto-ban engine penetration detection uses; "rate_limit" is now a valid threat_ban_config key. The suspicious-count structure is in-memory, per-process, and 10k-IP FIFO-capped, so multi-replica deployments do not share auto-ban counts. Async and sync mirrors updated identically.
  • DynamicRules.auto_ban_threshold, auto_ban_duration, enable_rate_limit_auto_ban: Guard Agent dynamic-rule pushes can now tune auto-ban at runtime. Applied with the same setattr-plus-log convention as the existing enable_ip_banning toggle; all three restore on rollback.
  • DynamicRules.expires_at is honored. A dynamic rule push with a non-null expires_at auto-reverts its config mutations once that time passes. Naive (tzinfo-less) expires_at is treated as UTC. A rule whose expires_at is already past on receipt now never activates, instead of driving a revert-then-reapply loop every tick.
  • SecurityConfig.auth_verifier: global default verifier callable for require_auth and api_key_auth.
  • require_authorization_header(scheme=...): presence/scheme-prefix-only decorator, documented as NOT authentication.
  • Verifier contract: verifier(request, credential) -> Principal | None. Sync or async in async (ASGI) deployments; sync-only in WSGI. An async verifier in WSGI, or any verifier that raises, is rejected with 401 fail-closed. The authenticated principal is stored on request.state.auth_principal.
  • Dedicated Detection Gate CI workflow (.github/workflows/detection-gate.yml) runs on every PR and master push, fails on sync-tree drift, and runs both detection benchmarks as a named job whose failure message names the regressed category.
  • PatternCompiler.validate_pattern_safety hardened. New structural rules flag adjacent broad unbounded quantifiers, ambiguous optional tails in quantified groups, unreachable terminator scans, and ambiguous literal boundaries. The runtime probe runs in a killable subprocess (subprocess.run(..., timeout=2.0), never a thread the caller waits on). validate_pattern_safety accepts an optional max_content_length and probes at the caller's real cap; every SecurityConfig-aware call site passes its own cap. Numeric backreferences (\1-\9) are now resolved in the reach probe; unresolvable backreferences and unreachable regions fail closed. Sync mirror updated in lockstep.
  • _MAX_USER_AGENT_MATCH_LENGTH = 512 (guard_core/utils.py): the User-Agent header is truncated to 512 characters before any pattern runs against it, the request-body path's detection_max_content_length cap's missing counterpart. Chosen from ASP.NET's documented precedent. Sync mirror updated in lockstep.
  • guard_core.detection_engine.scan_window (bounded_search, bounded_finditer, mirrored under guard_core.sync.detection_engine.scan_window): a drop-in replacement for compiled.search(text) that bounds the regex scan window to where a match could possibly terminate, not the match length. No length cap, no N+1 bypass. Callers supply compiled, prefix, and terminator regexes built once per built-in pattern. Quote-aware (WHATWG HTML 13.2.5.34). Proven linear in a killable subprocess. Mechanism only; built-in patterns are wired in the Fixed section below.
  • New deserialization detection category (CWE-502) catches Java (rO0AB), Python pickle (gASV/gAWV plus GLOBAL-opcode text markers), PHP (O:/C:/E:), .NET BinaryFormatter (AAEAAAD) plus <ObjectDataProvider, and Ruby Marshal (BAh[Jv7bV]) serialized payloads in their base64 wire form. Raw bytes are structurally unreachable (the body decode rejects non-UTF-8), so only the base64 wire form is matched.
  • Known-mechanism disclosure: UTF-16/UTF-32 wide-encoded payloads are detected, but as an accident of null-byte removal, not a designed capability, and now pinned (tests/test_detection_engine/test_wide_encoding_detection.py). EBCDIC codepages (cp037, cp500, etc.) miss outright and are left unfixed as scoping data.
  • Committed adversarial ReDoS corpus (tests/test_sus_patterns/test_redos_backstop_corpus.py): one maximally-adversarial payload per confirmed-quadratic built-in pattern family, run through the real sus_patterns_handler.detect() production entry point. Sync mirror updated in lockstep.

Changed

  • CI enforces --cov-fail-under=100 and -W error. A coverage drop or a new warning now fails the build instead of accumulating. make lint runs ruff format --check . instead of rewriting in place; use make fix to apply formatting.
  • Built-in detection patterns no longer compile with re.MULTILINE. The global flag was the root cause behind five categories of false positives 3.12.0 fixed anchor by anchor; only three SSRF anchor usages remain, all with adjacent \s alternatives that already consume newlines. User-supplied patterns keep re.IGNORECASE | re.MULTILINE unchanged. Sync mirror updated in lockstep.
  • cmd_injection flags structure, not a ~60-name hardcoded command list. ${...} flags on POSIX bare-parameter-name grammar failure, $(...) keeps its ambiguous-context gate, quote-splice flags 3+ consecutive single-char fragments, glob-wildcard requires a preceding shell operator, brace-expansion requires start-or-operator anchoring and excludes :/quote from item grammar. ${whoami} in bare prose is intentionally undetected (parameter expansion, not execution). Per-category recall baselines lowered as a ruled reduction, not a moved goalpost. Sync mirror updated in lockstep.
  • Structural rules become pre-filters; a timed cost arbiter decides. validate_pattern_safety's default path builds every adversarial probe a battery of strategies can produce, times each at 4/8/16/32k chars using time.process_time() in a killable subprocess (minimum of 5 samples, median drives the verdict), and rejects only if the growth-extrapolated cost at the caller's real cap exceeds 50ms. A structural hit supplies the rejection reason when the arbiter also rejects and forces fail-closed rejection when no probe can be built; a structural hit measured under budget and linear no longer rejects on its own. The known-quadratic built-in constant grows from 22 to 35 entries, driven by measurement, not hand-picking. Sync mirror updated in lockstep.

Fixed

  • LDAP forward-window and backtick context-window length cliffs. Both used a fixed 40-character window; a corroborating token 40+ chars away fell outside and silently stopped detecting. Replaced with scans bounded by the construct's own natural delimiters (balanced parens, quotes, newlines) and the next candidate start. No magic number, no cliff to relocate (CVE-2025-54365). Sync mirror updated in lockstep.
  • Four {0,N}-capped patterns converted to scan-window finders. _LDAP_NULL_BYTE_ATTR_RE/_LDAP_NULL_BYTE_DECODED_ATTR_RE (value {0,255} cap was a live bypass), _QUOTE_SPLICE_CANDIDATE_RE, _GLOB_WILDCARD_ATOM_RE route through _WINDOWED_PATTERN_FINDERS in suspatterns_handler.py; the {0,N} cap is a length cliff by construction (CVE-2025-54365) and an unbounded quantifier is quadratic (CVE-2025-53539), so neither is acceptable alone. Three template cap patterns ({{...}}, ${...} SSTI) stay capped pending a corrected shared bounded_finditer that an independent fuzz found silently drops matches. Sync mirror updated in lockstep.
  • Eleven built-in PREFIX[^x]*TERMINATOR patterns route through bounded_finditer. Script, style-expression, object, embed, applet, dir-traversal ..;, file-inclusion URL, XML entity/DOCTYPE SYSTEM, CDATA, DOCTYPE-external-entity, and the SSTI #{...} shape. CPU timing linear at the 262144-byte cap. Outcome parity differential-fuzzed per pattern (20,000 trials each, zero mismatches). Sync mirror updated in lockstep.
  • 26 recon/sensitive_file/cms_probing path-probe patterns carried a released quadratic-backtracking DoS since the 723e0882 "Initial release" commit (0.1.0 and every tag since). The prefix group's own character class fully contains the mandatory literal that follows it. Fixed with a segment guard (_path_only_pattern, a negative-lookahead-disambiguation idiom). 25 of 26 now measure safe through the arbiter at the real 262144 cap. Sync mirror updated in lockstep.
  • Regex-match timeouts on the shared scan pool now report as a threat. A time.process_time()-bounded regex that never finished is unknown, not absent; _check_regex_patterns now builds a pattern_timeout threat entry when timeout_occurred is true and no real match was found. Sync mirror updated in lockstep.
  • Double validation on every dynamic-rule apply and rollback removed. _apply_user_agent_rules and DynamicRuleManager._restore_config now use SecurityConfig._set_prevalidated, which skips only the _FIELD_REVALIDATORS re-check; the general assignment path is unweakened. The _skip_revalidation flag moved off the instance to a contextvars.ContextVar after a reproduced thread race and a model_copy double-bypass. A pinned test fails if _set_prevalidated is ever made async or grows an await between set and reset. Sync mirror updated in lockstep.
  • ContentPreprocessor.truncate_safely and the preprocessor now treat detection_max_body_inspect_bytes as the single memory bound. A hardcoded 262144-byte scan window, a MAX_SHORT_BASE64_SCAN_BYTES = 2_000_000 pre-slice, and a MAX_GUNZIP_OUTPUT_BYTES = 8192 decompression cap all silently ignored a raised detection_max_body_inspect_bytes; the scan cap is now wired from the caller at both singleton and per-request sites, the pre-slice is deleted, and the gunzip output cap threads through from the knob. decode_common_encodings's 16-iteration budget now raises a synthetic custom weight-1.0 threat when content is still changing on the final iteration. max_decode_iterations raised from 7 to 16. Sync mirror updated in lockstep.
  • _BASE64_RE floor lowered from {20,} to {12,}. A payload whose encoded form falls under 20 characters was never a decode candidate; 1' OR '1'='1 (16-char blob) now detects. Measured 0 new false positives across a 2034-case benign base64-ish corpus at every threshold. Sync mirror updated in lockstep.
  • Four encoded-path-traversal evasion classes closed in ContentPreprocessor. Newline/MIME line-wrap inside a base64 blob, gzip-then-base64, overlong-UTF-8 percent-byte runs (%c0%af, %e0%80%80%af, %f0%80%80%80%af), and %uXXXX IIS-style percent-u escapes are now decoded. A Unicode-lookalike renormalization pass folds decoded separator lookalikes to literal / or \. path_traversal now compares the decoded view against the raw view and flags when decoding revealed a ..[/\\] the attacker tried to hide. A new dir_traversal pattern catches ..;/ semicolon path-parameter bypass. Sync mirror updated in lockstep.
  • SemanticAnalyzer was quadratic on plain text. Four attack_structures patterns (tag_like, function_call, path_traversal, url_pattern) had unbounded greedy quantifiers whose class did not exclude their own start characters. Bounded to finite maximums; tag_like is unbounded again but scanned only up to its last > via _tag_scan_window, the general technique for this defect class. extract_tokens no longer spins up ten ThreadPoolExecutor instances per call. Sync mirror updated in lockstep.
  • ban_ip refuses loopback and trusted-proxy targets. A reverse-proxy deployment with trusted_proxies unset previously banned 127.0.0.1 and locked out all traffic. Refusal is a logged warning, never an exception; no opt-out. A Redis-down ban above the 1-hour local-cache cap now clamps to 1 hour instead of raising (previously: ValueError with fail_secure=True returned HTTP 500 for every request; fail_secure=False silently applied no protection). Sync mirror updated in lockstep.
  • OtelHandler and LogfireHandler no longer clobber or destroy providers they do not own. OtelHandler.start() claims ownership only if it won the set-once race; stop() shuts down only a provider it recorded owning. LogfireHandler.start() checks logfire.DEFAULT_LOGFIRE_INSTANCE.config._initialized before calling configure(); stop() calls logfire.shutdown() only when this instance configured. Both are idempotent in any order. A LogfireHandler.start() that failed configure() no longer gets permanently stuck refusing to retry. Sync mirror updated in lockstep.
  • scripts/unasync.py --check no longer reports a missing sync mirror as "up to date" or deletes a mirror it just generated. Check mode generates into a temporary .unasync_check_tmp_ directory and compares against the untouched real tree; a missing mirror prints MISSING: and exits non-zero, an orphan prints ORPHAN:. Pinned with a test. Sync mirror updated in lockstep.
  • BehaviorTracker Redis sink no longer interpolates endpoint_id, client_ip, or rule.pattern into a KEYS glob. All three now pass through hashlib.sha256 per segment, closing a glob-injection sink and a field-boundary collision. Counting is now RedisManager.record_sliding_window_hit (one pipelined ZADD/ZREMRANGEBYSCORE/ZCARD/EXPIRE), no keyspace-wide KEYS scan. The sorted-set member is uuid.uuid4().hex, not the timestamp, so identical-timestamp hits each count separately (a 3000-request burst previously collapsed to 166 entries). Upgrading resets every identity's in-window counter to zero; old keys self-expire within one window. Sync mirror updated in lockstep.
  • Four file_upload filename= sites carried a {0,255} cap, a live shipping bypass. A filename with a 256+ char prefix evaded detection. Caps removed; a new _file_upload_scan_window truncates each scan to the last quote character, linear per position with no length cliff. asmx, cer, phps join the dangerous-extension list. The double-extension benign-terminal whitelist drops archive extensions (zip/gz/tar/rar/7z) that false-positived on backup and release-artifact filenames. Sync mirror updated in lockstep.
  • Three xss patterns (event-handler, href/src/data/action, style=expression) were genuinely quadratic. Fixed structurally, not by capping: a single non-quantified atom between the two adjacent broad quantifiers removes the ambiguous split. The event-handler pattern is on\w+ scoped to a real tag/attribute shape (replacing an 11-name hardcoded enum), gated by a shared _HTML_TAG_OPEN_RE = r"<[A-Za-z/]" so a < used as a less-than operator no longer opens a false tag, with a 200-name event-handler allowlist derived from PortSwigger and OWASP. HTML5 whitespace around = is now tolerated (\s{0,20}) in xss and file_upload. Sync mirror updated in lockstep.
  • A non-UTF-8 request body skipped the ENTIRE body scan in every released version (CWE-693). Appending or prepending a single invalid byte evaded every body-delivered pattern. The decode is now lossy (errors="replace"); the honeypot trap-field decoder matches. Sync mirror updated in lockstep.
  • The request/response ingestion boundary now decodes invalid UTF-8 consistently across five sites using errors="surrogateescape". Two were live bypasses (top-level raw-body decode, parse_qsl in _scan_form_body, _multipart_text_parts); the other three (honeypot form/JSON, response body) are consistency fixes. A single _sanitize_for_reporting helper round-trips scanned content through surrogateescape then backslashreplace at the six reporting choke points, so a lone surrogate reaching a FileHandler no longer silently drops the audit-log entry. This is a lockstep-upgrade requirement across the ecosystem. Async and sync mirrors updated identically.
  • Nine detection categories matched only the literal spelling of an attack technique. proto_pollution (Object.prototype assignment, Object.setPrototypeOf/Reflect.setPrototypeOf), xml (PUBLIC external DTD), xss (java\tscript: control-char scheme), nosql (boolean $ne/$eq under a field key, not numeric range), file_inclusion (JSON value under template/include/tpl/module/layout), ssrf (bare metadata/instance-data host aliases), cmd_injection (Node child_process, PHP assert, Python os.exec*), code_injection (__import__('os').system), and eval( equivalents (Function, window['eval'], .constructor.constructor, setTimeout/setInterval string arg). Each adds a structural discriminator rather than another alias. Sync mirror updated in lockstep.
  • Seven detection categories widened to new request contexts. xml + query_param, xss + url_path, cmd_injection + header (resolving an internal contradiction with the existing Log4Shell row), ssrf + url_path and header, template + url_path, deserialization + header. sqli + url_path measured a real false-positive cost and is excluded. Three already-accepted benign-corpus tradeoffs became visible on the newly-exposed axis and are pinned. Sync mirror updated in lockstep.
  • New nosql [$op] bracket-operator pattern, proto_pollution arbitrary-key pattern, template {{}}/#{} shape-gated pattern, sqli tautology pattern, and cmd_injection bare $(...)/${...} + dedicated Log4Shell JNDI pattern. cmd_injection gains 10 tool names (nmap, socat, msfconsole, msfvenom, certutil, bitsadmin, powershell, pwsh, mkfifo, aria2c) and absolute-path/env-prefixed shell invocation. xss slash-separated handlers (<svg/onload=) and ssrf userinfo (http://x@localhost/) detected. sqli gains CREATE, stacked SELECT...FROM/REPLACE INTO, and EXEC xp_/sp_ system-proc patterns. Explicit-scheme RFI (?page=http://attacker/shell.php) is a dedicated file_inclusion pattern. Sync mirror updated in lockstep.
  • Request parameter NAMES now scanned for injection patterns. _scan_query_params, _scan_headers, _scan_json_value, _scan_form_body, _scan_multipart_body, and the embedded-JSON-key path feed the name to the same pipeline as the value. ?username[$ne]=admin, ?__proto__[isAdmin]=true, a JSON body key "1;DROP TABLE x", or a multipart field named __proto__[x] are detected. An excluded header's name gets only the Log4Shell JNDI shield, preserving the GHSA-motivated excluded-header design. Structured JSON body operator keys ({"username":{"$ne":null}}) are detected at any depth. Numeric range operators ($gt/$gte/$lt/$lte with a numeric literal) are not flagged as NoSQL injection: they are indistinguishable from legitimate range queries. Auth-bypass shapes ($ne null or boolean, $gt "", $regex, $where, $exists, $in) are flagged. Sync mirror updated in lockstep.
  • _LDAP_WILDCARD_EQUALS_RE repurposed into a whitespace-tolerant breakout matcher. The crontab false-positive class and its entire exclusion mechanism are deleted; requiring parens structurally retires the name list. Extensible-match attribute syntax (attr:dn:matchingRule:=value), no-wildcard breakout (admin)(cn=x)), comparator breakout (~=/>=/<=), RFC 4515 backslash-hex escapes (\29\28), and a backward depth-scan length cliff ("x"*n + "*)(uid=*" stopped detecting at n=40) are all closed. The _ldap_wildcard_chain_is_injection validator is split into three functions to stay under the xenon ceiling. Sync mirror updated in lockstep.
  • _LDAP_ATTR_EXTENSIBLE_MATCH_RE had an unbounded, uncancellable event-loop hang. The :dn/[\w.-]+ alternation branches overlap completely, giving 2^n equivalent parses; a 77-byte body hung the event loop for 7.76s. The redundant dn branch is removed; a killable-subprocess regression test pins detect() returns within 2 seconds on "*)(a" + ":dn"*30 + "X". Sync mirror updated in lockstep.
  • sqli WHERE-clause corroboration reverted; tautology pattern added. _WHERE_CLAUSE_RE no longer corroborates on a placeholder-shaped value; a new _SQLI_TAUTOLOGY_RE (\b(?:OR|AND)\s*(\d+|'[^']*'|"[^"]*")\s*=\s*\1\b) detects 1 OR 1=1-- and placeholder-prefixed tautologies at 1.0 category weight with no WHERE/SELECT dependency. SusPatternsManager._normalize_context(None) no longer raises. Sync mirror updated in lockstep.
  • Pickle GLOBAL-opcode anchor relaxed and validator hardened. Any single legal opcode prepended before GLOBAL previously defeated detection; a bounded, non-executing structural validator (_pickle_global_prefix_is_opcode_stream) replays the up-to-32-byte prefix window against a pickle._Unpickler subclass whose find_class/get_extension/persistent_load unconditionally raise. FRAME is handled directly in the dispatch loop (the predicted short-read cause was wrong; the real defect was an AttributeError). The validator's read/readline/readinto now raise on short reads instead of silently accepting truncated parses. Identifier quantifiers bounded to 100 chars/segment, 20 segments to close a quadratic ReDoS. Sync mirror updated in lockstep.
  • Negative JSON-structure result no longer suppresses the raw pattern scan. _check_value_enhanced's embedded-JSON pre-check returned unconditionally on any non-None result, so {"username":{"$ne":null}} nested one level down was invisible on query_param/header. The early return now happens only when detected is true; a negative JSON result falls through to the raw scan. Sync mirror updated in lockstep.
  • block_cloud_providers's :!region carve-out has never worked in any release that shipped it (3.2.0 through 3.12.0). Every refresh path looked the range fetcher up by the literal selector string, the KeyError was swallowed, and the provider's IP ranges stayed empty forever. All four lookup sites now normalize selectors to bare provider names before lookup. Sync mirror updated in lockstep.
  • integration-test isolation and Makefile cache-cleanup fixes. Four entrypoints hardcoded a shared REDIS_PREFIX that shadowed the PID-scoped default and let one suite's teardown wipe another's keys; Makefile and compose.yml now pass REDIS_PREFIX through only when set, and CI jobs drop the hardcoded value. make integration-test now runs the async and sync OTel integration tests as separate uv run pytest invocations so the process-global OTel providers do not collide. The 20 Makefile recipes that piped find . into xargs rm -rf now share one CLEAN_CACHES variable that hands each matched path to rm -rf as its own argument regardless of embedded whitespace. Sync mirror updated in lockstep.
  • RateLimitManager.reset() now clears redis_handler. A previously-attached Redis handle no longer survives reset() into a later test or production re-init. Sync mirror updated in lockstep.
  • Embedded-probe-in-prose known limitation disclosed, not shipped. An attack probe path embedded inside a full prose sentence (Note: the scanner hit /wp-admin/install.php) is not detected; the whole-string anchor that stops benign documentation prose from false-positiving is exactly what makes a real probe invisible inside a sentence. Three corroboration strategies were measured against a 32-malicious/43-benign corpus; none cleared both recall and false-positive gates, so this stays a documented gap rather than a shipped mitigation.
  • Test-hygiene: a "not a working attack" pin no longer calls real pickle.loads on attacker-shaped bytes. It now loads through a _BlockingUnpickler whose find_class raises before resolution, same evidential value with no path to real code execution. Sync mirror updated in lockstep.

v3.12.0 (2026-08-12)

Exclusion-path bypass closed, excluded paths now enforce bans and rate limits, identity-block escalation no longer bypassed by a stale whitelist flag, bounded body reads are timeout-bounded, and Azure range fetching is hardened (v3.12.0)

Added

  • guard-core now reports its own version automatically. guard_core.__version__ is resolved once at import from installed package metadata (falling back to "unknown" when metadata is unavailable, such as a source checkout), and is forwarded to the agent as guard_core_version with no operator action required. The pre-existing agent_guard_version field is unchanged and still carries the framework wrapper version, which is operator-supplied; because adapters declare guard-core without a version constraint, the wrapper version cannot identify which guard-core is actually installed, and guard_core_version can.
  • BoundedBodyReader and SyncBoundedBodyReader (guard_core/protocols/request_protocol.py / guard_core/sync/protocols/request_protocol.py): a new, optional capability protocol, async def read_body_prefix(self, max_bytes: int) -> bytes, that an adapter implements alongside GuardRequest to let detection inspect a size-capped prefix of a request body that has no usable Content-Length (for example chunked transfer-encoding), without reading or buffering the rest. It is exported from guard_core.protocols.__all__ / guard_core.sync.protocols.__all__ and reachable as guard_core.BoundedBodyReader / guard_core.sync.SyncBoundedBodyReader from the start.
  • BoundedResponseBodyReader (guard_core/protocols/response_protocol.py) and its blocking mirror SyncBoundedResponseBodyReader (guard_core/sync/protocols/response_protocol.py): the response-side counterpart of BoundedBodyReader, async def read_body_prefix(self, max_bytes: int) -> bytes, that an adapter implements alongside GuardResponse to let return_pattern behaviour rules inspect a size-capped prefix of a response body without buffering the rest and without disrupting delivery of the full body to the client. Exported from guard_core.protocols.__all__ / guard_core.sync.protocols.__all__ and reachable as guard_core.BoundedResponseBodyReader / guard_core.sync.SyncBoundedResponseBodyReader. Two new SecurityConfig fields control it: behavior_scan_response_body: bool (default False) gates response-body reading for return_pattern rules entirely, and behavior_max_response_body_inspect_bytes: int (default 262144, range 1024-10485760) bounds how many bytes guard-core reads and retains per response. Because the response body is application-produced rather than attacker-supplied -- an attacker who finds any large streaming endpoint (a file download, an export, an SSE stream) controls which endpoint they hit, not what it produces -- this cap bounds what guard-core itself retains, not what the endpoint produces; it is not a full-body scan guarantee, and adapter implementations of read_body_prefix must keep a streaming response streaming to the client after inspection (buffer up to the cap, then replay it plus the untouched, unbuffered remainder of the original stream) rather than reading the whole body before forwarding any of it, or every large download becomes a memory spike and every SSE/long-poll connection breaks. Both obligations are spelled out in the protocol docstring with the same force as BoundedBodyReader's GHSA-xv6g-49vj-7w9c memory-obligation language, since guard-core has no way to enforce either from the caller side.
  • SecurityConfig.body_read_timeout: float (default 3.0 seconds, range 0.0-30.0 exclusive of zero): the wall-clock bound asyncio.wait_for applies, in the ASYNC guard_core tree only, to every adapter call guard-core makes through BoundedBodyReader.read_body_prefix, BoundedResponseBodyReader.read_body_prefix, and the plain GuardRequest.body read. The SYNC tree (guard_core.sync) calls the adapter's read directly and does not use this value at all. See the body-read timeout entry below.
  • Constructing a SecurityConfig with a global_behavior_rules return_pattern entry whose pattern is not status:-prefixed while behavior_scan_response_body is False now raises ValueError naming the offending pattern instead of silently accepting a rule that could never match (validate_global_return_pattern_body_scan). @security.return_monitor() and @security.behavior_analysis() (guard_core/decorators/behavioral.py) reject the identical combination at decoration time for per-route rules, via the same _validate_return_pattern_body_scan helper.

Fixed

  • RequestValidator.is_path_excluded matched exclude_paths with a plain str.startswith and no normalisation or path-boundary check, and BypassHandler.handle_passthrough returned call_next(request) the moment it matched, before the client IP was even extracted. Because /static ships in the default exclude_paths, any request whose path merely began with an excluded entry skipped the entire security pipeline: detection, rate limiting, IP banning, user-agent filtering and emergency mode. /staticadmin, /redoc-admin/delete-all, /static../.aws/credentials and /static/../../../root/.ssh/id_rsa were all treated as excluded, and a banned IP still reached them. Present in every released version. Path matching now lives in a pure guard_core.core.validation.path_matching module that percent-decodes recursively (bounded, failing closed if still encoded), folds backslashes, collapses dot segments, and then requires an exact match or a true /-bounded prefix; anything that cannot be confidently normalised is never excluded, so it receives the full pipeline.
  • Because the new path-matching module above normalises before comparing, a degenerate exclude_paths entry such as '', '.', '..', '//', '\\', '%2f' or '%2e%2e' normalises to /, which matches every path and would disable the entire security pipeline application-wide -- the same blast radius as the bug above, reachable this time by a configuration mistake (a trailing comma in an env-var-driven list produces '') rather than a crafted request. A shared _validate_exclude_paths_value helper rejects such entries with a ValueError naming the offending value, and is invoked from every place exclude_paths can be set: the field_validator at construction, SecurityConfig.__setattr__ for a direct runtime assignment to exclude_paths, and an overridden model_copy when its update touches exclude_paths. A literal '/' is still accepted, since an operator may mean it, but warns loudly (attributed to the caller's line in all three paths) about what it disables. SecurityConfig deliberately leaves validate_assignment unset: turning it on for the whole model would also re-run every other field's validator, and the country-shadow model validator, on every assignment, unaudited side effects this fix does not need.
  • The path_excluded event was emitted on every request to an excluded path, uncached and unsampled. Orchestrator liveness probes hitting /healthz, /health, /metrics, /ready and /live on a timer therefore generated one telemetry event per probe, indefinitely. Emission is now throttled through a TTLCache(maxsize=1000, ttl=300) keyed on the normalised path, mirroring the existing throttle on security_headers_applied, so a repeatedly-probed path emits at most one event per five minutes. The throttle gates only the event: the exclusion decision itself is computed before and independently of any cache lookup and can never be influenced by cache state in either direction.
  • RequestValidator._current_normalized_exclude_paths caches the normalised exclude list keyed on the exclude list's own content (tuple(config.exclude_paths), compared by value against the tuple captured at the last recompute), so any mutation that changes what exclude_paths actually contains -- a whole-value reassignment or a size-preserving in-place edit such as config.exclude_paths[1] = "/other" -- is picked up on the very next request regardless of whether anything else on config changed.
  • escalate_suspicious_if_threat is renamed to escalate_identity_violation (guard_core/core/checks/helpers.py). Separately, IpSecurityCheck.check() computed request.state.is_whitelisted -- the flag escalate_identity_violation and several other checks (UserAgentCheck, CloudProviderCheck, SuspiciousActivityCheck, RateLimitCheck) read to skip their own logic for a request the global whitelist already vouches for -- by precomputing it via the new _resolve_global_ip_access before calling _check_route_ip_restrictions, and leaving it set while that route-level check ran. A route that blocks an IP through its own route_config.ip_blacklist is a decision _resolve_global_ip_access never evaluated, but with the flag already sitting on request.state from the global check moments earlier, escalate_identity_violation's own is_whitelisted guard saw it as True and returned immediately: a route-level block for an IP that also happens to sit on the global config.whitelist was silently never escalated, regardless of the payload -- suspicious_request_counts stayed empty, ban_ip was never called, and no EVENT_PENETRATION_ATTEMPT was ever emitted for it, even for a real SQLi payload. IpSecurityCheck.check() no longer writes request.state.is_whitelisted before the route-level check; it is set (unconditionally, whether the request is allowed or blocked) only inside _check_global_ip_restrictions, the one place that actually performs the global whitelist evaluation the flag is meant to describe. A route-level block therefore always reaches escalate_identity_violation with the flag at its unset (falsy) default, and only a genuine global-whitelist allow -- or a route that defers to the global check because it has no ip_whitelist/ip_blacklist of its own -- sets it True. Tests now cover the same IP appearing in both config.whitelist and route_config.ip_blacklist (escalates normally) and in both config.whitelist and route_config.ip_whitelist (request is allowed, as before); the previously-existing tests used disjoint IPs for the global and route lists, which is why this was missed.
  • escalate_identity_violation increments suspicious_request_counts and checks threat_ban_config using the real detected threat_categories from get_cached_detection_result (falling back to ["uncategorized"] when detection ran but returned none), not a synthetic label describing why the request was identity-blocked; that synthetic label (ip_restriction, ip_blocked, or user_agent) is still attached to the emitted penetration_attempt event as violation_category for observability, but does not feed the ban counters.
  • SuspiciousActivityCheck no longer mutates middleware.suspicious_request_counts through its own private method (_increment_per_category), which had no lock, no _MAX_TRACKED_SUSPICIOUS_IPS cap, and no LRU touch-on-access; it now calls the same shared _increment_suspicious_counts helper escalate_identity_violation uses, so there is exactly one writer to suspicious_request_counts in each of the async and sync trees, and the lock/cap/LRU protections below cover the primary detection path, not only the identity-block escalation path. _increment_suspicious_counts now caps the tracker at _MAX_TRACKED_SUSPICIOUS_IPS (10,000) entries and touches (moves to most-recently-used) the tracked IP on every increment, evicting the coldest, longest-untouched entry first on overflow, so an attacker rotating through a large IP pool cannot push their own actively-tracked entry out of the map to reset their ban tally. The critical section (plain dict manipulation, no await inside it) is guarded by a threading.Lock in both the async and sync trees -- loop-agnostic, since it never needs to be released across an await -- so _increment_suspicious_counts is a plain def in the async tree too, not async def.
  • get_cached_detection_result caches the per-request DetectionResult on request.state, keyed on the identity of both the request and the route_config object it was computed for (cached[0] is request and cached[1] is route_config), so detect_penetration_attempt runs at most once per request regardless of how many checks (escalate_identity_violation, SuspiciousActivityCheck) need the result, and a cache entry can never be served to an unrelated request or route.
  • escalate_identity_violation's exception handler wraps every logger.exception(...) call (both the top-level failure log and the ip_ban_failed event-bus report) in its own try/except, so a failure in the logging sink itself (a broken handler, a full disk) cannot escape the handler and turn the caller's already-decided block response into an unhandled exception.
  • fetch_azure_ip_ranges in the prior release had no elapsed-budget concept at all: the page-scrape request used a fixed 10-second timeout, and the JSON download used a fixed 30-second timeout retried up to 3 times with a flat 2-second sleep between attempts, so the theoretical worst case for the whole call was around 104 seconds (10 + 3x30 + 2x2). fetch_azure_ip_ranges now computes a single deadline (_AZURE_DOWNLOAD_MAX_ELAPSED_SECONDS, 20 seconds) at the start of the call and sizes both the page-fetch timeout and every download-attempt timeout from the time remaining against it, so the worst-case wall time for the whole call is bounded by that single 20-second budget instead of the sum of several independently-fixed timeouts.
  • Azure download-URL discovery now tries three extractors in priority order -- an id="failoverLink" anchor, an href match, and a plain-text ServiceTags_Public_*.json URL match -- and validates every candidate URL against the same allowlist before using it: the scheme must be https and the parsed hostname must equal download.microsoft.com exactly, so a lookalike host such as download.microsoft.com.evil.com is rejected rather than matched as a prefix. The prior release had only the latter two extractors and validated neither, so a compromised or MITM'd page could point the download at an attacker-controlled host or an internal address and have the response fetched and trusted as Azure CIDR data. The download request itself (_download_azure_service_tags) now also passes allow_redirects=False (the prior release's inline download used aiohttp's, and in the sync tree requests's, default of following redirects), and any 3xx response now raises a ValueError before raise_for_status() or JSON parsing runs -- a passed-allowlist URL could otherwise still answer with a redirect to a completely different origin and have that response trusted, defeating the host allowlist regardless of how strict it is. A URL that genuinely redirects now fails the same way every other Azure fetch error already does: logged, an empty range set for that refresh, no false blocking of legitimate Azure IPs. When more than one dated ServiceTags_Public_*.json URL is present on the page, selection now parses the 8-digit date in the filename as a real calendar date (rejecting one that fails to parse or is in the future) and orders candidates deterministically (has a valid date, then the date itself, then the URL string), instead of picking whichever candidate the discovery regex happened to match first; a warning is logged after the winner is chosen when it has no parseable date at all, or when its date is more than 90 days old, so a silently-stale fallback pages an operator instead of running unnoticed. The retry loop that wraps this download also narrows what it retries: the prior release's single except Exception spanned the request, the status check, and the JSON parse, so a connection error, a bad HTTP status, and a malformed JSON body were all retried identically up to three times. _download_azure_service_tags now retries only session.get raising (a connection-level failure); a response that comes back with a bad status (raise_for_status()) or a body that fails to parse as JSON (response.json()) is outside the retried try/except and fails the attempt immediately, since retrying the same URL cannot change either outcome. A failed fetch still resolves to an empty range set either way, so this does not change false-blocking risk, only how many attempts (and how much added latency) a non-transient failure costs; test_fetch_azure_ip_ranges_download_failure is renamed to test_fetch_azure_ip_ranges_bad_status_is_not_retried to match, alongside a new test_fetch_azure_ip_ranges_bad_json_body_is_not_retried.
  • The country-shadow warning (warn_country_allowlist_shadows_blocklist, added in the prior release) is a model_validator(mode="after"), which -- since SecurityConfig does not set validate_assignment -- only ever ran at construction; a runtime assignment such as config.blocked_countries = ["CN"] after whitelist_countries was already set never re-checked the shadow condition at all. SecurityConfig.__setattr__ now re-runs the same check when either blocked_countries or whitelist_countries is assigned directly, deduplicated against the value already stored for that field so a periodic dynamic-rule re-sync that reassigns the same countries on every poll interval does not re-warn on every cycle; both sides of the dedup comparison are normalised to frozenset[str] first, since country fields are stored as frozenset[str] and comparing the raw incoming value directly against the stored frozenset would re-warn every time the same countries were reassigned as a list/tuple/set rather than a frozenset. The truthiness check (self.whitelist_countries and self.blocked_countries) runs before the field is mutated and revision is bumped, so a value whose __bool__ raises aborts the assignment with no observable state change instead of leaving the object partially written.
  • SecurityConfig.model_copy(update={...}) bypassed the same country-shadow check the entry above closes for direct assignment: base.model_copy(update={"blocked_countries": [...]}) on a base with a non-empty whitelist_countries produced a copy with both lists populated and no warning, since model_copy neither runs warn_country_allowlist_shadows_blocklist (a construction-only model validator) nor goes through __setattr__. model_copy now re-runs the same check via _warn_country_allowlist_shadows_blocklist whenever its update touches whitelist_countries or blocked_countries, evaluated against the copy's final state, mirroring the exclude_paths handling the override already had for its own field. Unlike the __setattr__ path, this one is not deduplicated against the base's prior value: a model_copy call is a one-shot snapshot, not a stream of reassignments to compare against itself, and a fresh copy with both fields populated is shadowed regardless of what the base looked like. deep=, other update keys, subclass identity, and the plain no-update call are unaffected.
  • On a request with no usable Content-Length (for example Transfer-Encoding: chunked), the prior release's fail-closed Content-Length gate (_body_exceeds_inspection_cap) always skipped body inspection outright, since there was nothing to size the body against. This release replaces that gate with a bounded-read system: _parse_content_length parses a present Content-Length (tolerating surrounding whitespace, rejecting a leading +, thousands separators, hex notation, and non-ASCII digit forms as malformed) to decide whether the body is small enough to read in full, and falls back to _read_capped_body_prefix -- reading only up to detection_max_body_inspect_bytes through the new BoundedBodyReader capability -- when Content-Length is absent, so a chunked request can now be inspected up to the cap instead of being skipped entirely. Both branches (Content-Length present or absent) now share one request.state cache, keyed on request identity the same way get_cached_detection_result is, so two independent readers on the same request in the same pipeline run (for example @guard.honeypot_detection([...])'s validator and SuspiciousActivityCheck's body scan) both see the same prefix -- and pay body_read_timeout at most once between them -- instead of the second one draining an already-consumed single-use stream, scoring no threat, or paying a second full timeout on a stalled read. When an adapter's read_body_prefix/body returns something other than bytes, the cache logs a warning naming the request type, the accessor, and the offending returned type before falling back to the same fail-closed, treated-as-unscannable outcome a raising reader already produces. Only a successful read is ever cached; a failure (the adapter raises, the read times out, or it returns something other than bytes) is never written to request.state, so the next reader on the same request always gets its own fresh attempt instead of being served a stale failure a retry would have turned into a real, scannable body -- a transient ConnectionResetError on the first consumer no longer permanently disables body inspection for every later consumer of the same request. A genuinely empty body (b"") is a successful read like any other and is cached and shared exactly the same way.
  • Body-read timeout (async tree only). Nothing bounded the wait on an adapter's read_body_prefix/body call: a stalled SSE producer, a long-poll that never yields, or a buggy adapter implementation left _safe_read awaiting forever, pinning the request (and, at volume, every worker handling one) indefinitely -- a real, unbounded DoS with no recovery short of a process restart. _safe_read (guard_core/utils.py) now wraps the call in asyncio.wait_for(reader(), timeout=timeout); on timeout it degrades to the identical fail-closed, could-not-evaluate outcome already used when the reader raises, through whichever caller's existing throttled logging already covers that path (BehaviorTracker._log_body_unavailable's TTLCache on the response side; the request side already returns None silently for a raising reader and continues to). This bound is configurable via SecurityConfig.body_read_timeout (default 3.0 seconds) and applies uniformly to BoundedBodyReader.read_body_prefix, BoundedResponseBodyReader.read_body_prefix, and the plain GuardRequest.body read, since all three route through _safe_read -- in the ASYNC guard_core tree only. The SYNC tree (guard_core.sync) bounds the same wait with a semaphore plus a joined daemon thread. A blocking adapter call can't be cancelled from the outside, so _safe_read in guard_core/sync/utils.py hands each read attempt to its own daemon=True thread and joins it for up to the remaining timeout budget; thread growth is capped by a threading.Semaphore(sync_body_read_max_concurrent) (default 64) that the caller must acquire, also bounded by timeout, before the thread is even started. SyncGuardRequest.body, SyncBoundedBodyReader.read_body_prefix, and SyncBoundedResponseBodyReader.read_body_prefix all route through it, and both SecurityConfig.body_read_timeout and SecurityConfig.sync_body_read_max_concurrent apply to the sync tree exactly as they do the async one. If the semaphore can't be acquired in time, guard-core logs the concurrency limit being reached and treats the body as unavailable for detection, the same fail-closed outcome used when the join itself times out; a timed-out thread is left to keep running in the background, unjoined, until the adapter's own call returns.
  • IpSecurityCheck.check() now also runs _check_global_ip_restrictions (with route_config=None, so no per-route override participates) for a request marked guard_exclusion_scoped by BypassHandler.handle_passthrough, after the dynamic ip_ban_manager check. Previously an excluded path reached only the banned-IP check; config.blacklist, config.whitelist, config.blocked_countries, and cloud-provider blocking were not enforced there, so a statically blacklisted IP got a 403 on a normal path but was served on an excluded one. _check_global_ip_restrictions gained an escalate keyword, and the exclusion-scoped call passes escalate=False, so a block on an excluded path never runs penetration detection to categorise it for threat_ban_config/auto_ban_threshold; route-level IP/country restrictions (_check_route_ip_restrictions) remain skipped on an excluded path, matching every other route-decorator-driven check. Which checks still run at all on an exclusion-scoped request is now a SecurityCheck.enforced_on_excluded_paths: ClassVar[bool] = False class attribute that SecurityCheckPipeline.execute reads directly off each check instance (True on RouteConfigCheck, IpSecurityCheck, and RateLimitCheck, the same three checks enforced there before this change), rather than a name list living apart from the check classes it would otherwise have to name; a test asserts the set of checks derived from DEFAULT_CHECK_CLASSES with the attribute set matches exactly those three.
  • guard_core.models.SecurityConfig.dynamic_rule_interval had no floor, while the AgentConfig field it is forwarded to (guard_agent.models.AgentConfig.dynamic_rule_interval) enforces ge=60. Setting it below 60 did not raise by default, since SecurityConfig and AgentConfig are validated independently and agent construction only raises on a rejected value when agent_strict=True; otherwise a too-low value silently disabled the entire agent integration instead of erroring. dynamic_rule_interval now also enforces ge=60, matching the floor AgentConfig already requires. Every other SecurityConfig field forwarded to AgentConfig in to_agent_config() was checked against the installed AgentConfig's own Field constraints for the same class of mismatch: agent_status_interval already carries ge=60, le=86400, at least as strict as AgentConfig.status_interval's ge=60; agent_buffer_size, agent_flush_interval, agent_max_concurrent_flushes, agent_timeout, agent_retry_attempts, agent_backoff_factor, agent_max_payload_size, agent_compression_threshold, and agent_high_watermark_ratio carry no bound on either side, so there is nothing for either side to drift from. dynamic_rule_interval was the only mismatch found.
  • The file_inclusion protocol-relative-URL pattern in guard_core/handlers/suspatterns_handler.py matched any //host substring, with no check for what preceded the //. Every ordinary absolute URL (https://example.com, http://api.example.com) contains // immediately after its scheme's colon, so any request body, header, or param carrying a webhook URL, a profile link, or a URL mentioned in prose was flagged as a file-inclusion attack. Present in every released version. The pattern now carries a (?<!:) negative lookbehind on the //, so a // preceded by : (i.e. part of a normal scheme:// URL) no longer matches; a scheme-less protocol-relative reference such as //evil.com/shell.txt or ?file=//evil.com/x.txt, which is the actual RFI shape this pattern exists to catch, is unaffected, since nothing precedes its //. Dangerous URL schemes (php://, data://, zip://, phar://, etc.) are matched by a separate pattern immediately above this one and were never affected.
  • Fixing the pattern above removed an accidental side effect it had been relied on for: two SSRF seed payloads in the attack-simulation benchmark (http://169.254.169.254/latest/meta-data/, the AWS/GCP/Azure cloud-metadata endpoint, and http://localhost:8080/admin) were detected only because the over-broad file-inclusion pattern happened to match their //, not because the dedicated ssrf pattern actually matched them, it never did. That ssrf pattern's private/link-local branch appended exactly one more \d+ octet after a two- or three-octet prefix and then required a \s|$|/ boundary immediately after it, which no real four-octet IPv4 address (or a host:port such as localhost:8080) can satisfy; every "detection" credited to it for a real dotted-quad private IP or a localhost/loopback address with a port was actually coming from the unrelated file-inclusion pattern. The pattern now matches a full remaining octet run per prefix (169\.254(?:\.\d{1,3}){2}, 192\.168(?:\.\d{1,3}){2}, 10(?:\.\d{1,3}){3}, 172\.(?:1[6-9]|2[0-9]|3[01])(?:\.\d{1,3}){2}) and tolerates an optional :port before the boundary, so http://169.254.169.254/latest/meta-data/, http://localhost:8080/admin, and real 10.x.x.x/172.16-31.x.x/192.168.x.x targets are now matched by the ssrf category itself. The attack-simulation benchmark's detection_rate is unchanged (0.8568), now for the correct reason.
  • The cmd_injection shell-substitution pattern's separator character class was [;&|`], so a markdown code-span backtick wrapping a $()/${} reference (backtick, $(id), backtick) was matched as a shell separator immediately followed by a command/variable substitution, flagging ordinary documentation and support-ticket text such as "see the backtick-wrapped $(id) example" or "use backtick-wrapped ${HOME} in paths" as command injection. The class is now [;&|]; a real separator-prefixed substitution (; $(id), | $(whoami), & ${HOME}, with or without a space) still matches. A bare backtick-wrapped substitution with no leading separator is no longer detected by this pattern, which is not a regression: backtick and $()/${} are alternative, non-nesting shell substitution syntaxes, so wrapping one in the other is not a realistic attack shape, and a bare backtick-wrapped command with no $()/${} inside it was never matched by this pattern either way.
  • BehaviorTracker._check_response_pattern guarded response-body access with hasattr(response, "body"). hasattr swallows exceptions, and a framework adapter's GuardResponse.body is a property that raises AttributeError for a response whose body is not fully materialized (a streaming response in particular), so a body that could not be read was indistinguishable from a body that was absent. Every json:, regex:, and bare-substring return_pattern rule evaluated against such a response therefore silently returned False, a clean "no match" the code never actually computed, with only a throttled log line as a hint that something was being skipped. status: patterns, which read response.status_code and never touch the body, were never affected. _check_response_pattern no longer touches .body or hasattr for the body-reading formats at all: it now requires the response to implement the BoundedResponseBodyReader capability, detected with an explicit isinstance check rather than a property probe (safe against the same raising-property problem, since an isinstance check against a runtime_checkable Protocol never invokes a method member, only a property member), gated by the opt-in SecurityConfig.behavior_scan_response_body flag (default False, so upgrading changes nothing until it is turned on) and bounded by SecurityConfig.body_read_timeout (see above). When the flag is off, the capability is absent, read_body_prefix raises or times out, or it returns something other than bytes, _check_response_pattern returns None: a could-not-evaluate outcome distinct from False, logged through the same throttled TTLCache(maxsize=1000, ttl=300) this warning already used (keyed by pattern, at most once per five minutes per distinct pattern). track_return_pattern folds None into "no occurrence recorded", the same as False, so a rule that cannot be evaluated still never reports a match it did not observe -- it just also never records a false one. BehaviorTracker does not cache the response-body prefix it reads. An earlier iteration cached it in a weakref.WeakKeyDictionary keyed on the response object itself, on the theory that multiple return_pattern rules checked against the same response in one pipeline run should share a single read; that cache is removed, because measured production demand for it was exactly one return_pattern rule, and it broke correctness for every deployment paying its cost: a response type using __slots__ without __weakref__ (a realistic shape for a lightweight adapter wrapper) raised TypeError on the weakref.ref() the dict requires, which the caller's outer except Exception swallowed into a silent, permanent, process-lifetime False ("no match") for every return_pattern rule evaluated against that adapter's responses, logged only via an untethered logger.warning/logger.error on every single call rather than the same throttled TTLCache this code already uses elsewhere; and a response wrapper object whose identity is pooled/reused across logically distinct responses (kept alive specifically so it can be reused, which is exactly what defeats a weak reference ever expiring the stale entry) served the first response's cached body prefix to a pattern check running against a completely different, later response. Each return_pattern rule checked against a response now performs its own independent, bounded read through _safe_read; a deployment with several such rules configured against the same response pays that read once per rule instead of once per response, the accepted tradeoff for removing a cache that was actively wrong rather than merely redundant.

  • SecurityConfig.global_behavior_rules.append(...) (or .extend/.insert/slice-assignment) bypassed both validate_global_return_pattern_body_scan and the decoration-time check in @security.return_monitor()/@security.behavior_analysis() (see Added, above): a return_pattern rule with a body-reading pattern could be added to an existing SecurityConfig at runtime while behavior_scan_response_body was False and would silently never fire -- the exact rule shape construction already rejects, entering through a door neither validator was wired to. global_behavior_rules is now tuple[BehaviorRuleConfig, ...] instead of list[BehaviorRuleConfig] (see Behaviour changes, below), so .append/.extend/.insert/slice-assignment all raise AttributeError/TypeError immediately: the in-place-mutation path is closed outright rather than validated call by call. The two paths that remain -- whole-field reassignment (config.global_behavior_rules = (...)) and model_copy(update={"global_behavior_rules": (...)}) -- now re-run the same check construction uses, through SecurityConfig.__setattr__ and the model_copy override respectively, the same mechanism exclude_paths and the country fields already use in both methods. behavior_scan_response_body is covered symmetrically: reassigning it to False while global_behavior_rules already holds a body-reading return_pattern rule is rejected the same way, since disabling the flag out from under an existing rule reaches the identical silently-dead-rule outcome from the other direction, and model_copy(update={"behavior_scan_response_body": ...}) re-validates the copy's existing rules against the new flag value too. This does not cover mutating an individual BehaviorRuleConfig already inside the tuple in place (config.global_behavior_rules[0].pattern = "..."): BehaviorRuleConfig remains an ordinary, non-frozen Pydantic model, and nothing in the engine mutates one in place today, but closing that residual gap would need model_config = ConfigDict(frozen=True) on BehaviorRuleConfig and is left for a follow-up. Auditing every other SecurityConfig list/dict/set field for the same construction-vs-mutation gap found nine more with a real instance of it -- whitelist, blacklist, trusted_proxies (IP/CIDR format field_validators that raise), threat_ban_config (category-membership field_validator, raises), muted_event_types, muted_metric_types, muted_check_logs, enabled_detection_categories (membership field_validators, raise), and block_cloud_providers (silently filters rather than raising) -- all nine of which are closed in this same release too (see the next two entries). whitelist_countries/blocked_countries (frozenset, already immutable, so immune to in-place mutation) and exclude_paths/global_behavior_rules were, at that point, the only fields free of this class of bug. Also found, and also closed in this release: validate_geo_ip_handler_exists (the geo_ip_handler-requirement check, distinct from the shadow-blocklist check this release closes for model_copy) had the same __setattr__-reassignment gap the shadow check had before the prior release -- config.blocked_countries = [...] on a config with no geo_ip_handler and no ipinfo_token set the field with no error, and the resulting country check was then silently never consulted at request time.

  • The nine fields named above are now closed the same way global_behavior_rules was: an immutable type plus the identical __setattr__/model_copy re-validation wiring. whitelist (tuple[str, ...] | None, keeping its None "no whitelist" sentinel), blacklist, and trusted_proxies (both tuple[str, ...]) replace list[str], so .append()/.extend()/.insert()/slice-assignment now raise AttributeError/TypeError instead of mutating an unvalidated list. enabled_detection_categories, muted_event_types, muted_metric_types, and muted_check_logs replace set[str] with frozenset[str], so .add()/.discard()/.update() raise the same way. threat_ban_config replaces dict[str, ThreatBanConfig] with types.MappingProxyType[str, ThreatBanConfig] -- the standard library's read-only mapping view, since Python has no built-in frozen dict -- so config.threat_ban_config["xss"] = ThreatBanConfig(...) now raises TypeError ("'mappingproxy' object does not support item assignment") instead of silently bypassing the category-membership check validate_threat_ban_config already enforced at construction. block_cloud_providers replaces set[str] | None with frozenset[str] | None, closing the same in-place-mutation gap, and validate_cloud_providers now raises ValueError naming any entry whose provider name (the part before an optional :!region suffix) is not AWS/GCP/Azure, instead of silently dropping it -- this field was already unsafe at construction, not only on later mutation: SecurityConfig(block_cloud_providers={"AWS", "GPC"}) previously left GPC traffic completely unblocked with no error, warning, or log line anywhere. Every one of the nine fields' field_validators moved to mode="before" so the identical coerce-and-validate function backs both the constructor path (via Pydantic) and the new assignment/model_copy path (called directly), the same shared-function shape _validate_exclude_paths_value already established: config.whitelist = ["not-an-ip"] and base.model_copy(update={"threat_ban_config": {"bogus": ThreatBanConfig(threshold=1, duration=1)}}) both now raise the identical ValueError construction would, and a rejected reassignment leaves the field and revision unchanged, the same no-partial-state guarantee exclude_paths/global_behavior_rules already provide.
  • validate_geo_ip_handler_exists is closed the same way the country-shadow check was closed in the prior release: SecurityConfig.__setattr__ and model_copy now re-run it whenever blocked_countries, whitelist_countries, geo_ip_handler, or ipinfo_token changes after construction, evaluating the merged final state the same way construction does. config.blocked_countries = ["US"] on a config with no geo_ip_handler and no ipinfo_token now raises ValueError ("geo_ip_handler is required if blocked_countries or whitelist_countries is set") immediately instead of setting the field and leaving the country check silently unconsulted at request time; the field and revision are left unchanged on the raise. The construction-time convenience of auto-building an IPInfoManager from a set ipinfo_token is preserved on the assignment path too: reassigning blocked_countries/whitelist_countries on a config that already carries ipinfo_token (and no geo_ip_handler) auto-constructs the handler the same way construction does, and assigning config.geo_ip_handler = None while country rules are still active with no token set is rejected the same way the missing-handler case at construction is. model_copy(update={...}) re-runs the same check against the copy's fully merged state, so a single call that sets both keys together, base.model_copy(update={"blocked_countries": ["US"], "geo_ip_handler": handler}), still succeeds.
  • guard-core's ssrf category matched a target address only when it was written as a literal dotted-quad, localhost, or a small fixed set of hostnames, so an alternate encoding of an address already on that blocklist was not recognised at all: http://2130706433/, http://0177.0.0.1/, and http://0x7f.1/ all resolve to 127.0.0.1, which the literal pattern blocks, yet none of them matched anything. A new _decode_legacy_ipv4_host (guard_core/handlers/suspatterns_handler.py) implements the BSD inet_aton one-to-four-part decimal/octal/hex grammar and range-checks the decoded 32-bit address against the same private/link-local networks the dotted-quad pattern already recognises (0.0.0.0/8, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16), plus Alibaba Cloud's 100.100.100.200/32. Cloud metadata coverage had been AWS-only (169.254.169.254); GCP's metadata.google.internal and metadata.goog, and the same Alibaba address, are now matched by the hostname pattern alongside it. A single bare decimal integer under _MIN_BARE_DECIMAL_LEGACY_IPV4 (1 << 24, 16,777,216) is excluded from the decoded-address check, since every integer in that range decodes into 0.0.0.0/8 and the entire TCP port range would otherwise be reported as an SSRF target -- redis://6379, grpc://50051, and amqp://5672 are connection strings, not addresses, and are not flagged. The one value excluded from that exclusion is the canonical decimal form of 0.0.0.0 itself: http://0/ and http://0:8080/ still resolve to loopback and are still detected, rather than being swept into the same port-number carve-out as the connection strings around it.
  • ContentPreprocessor._decode_base64_candidates (guard_core/detection_engine/preprocessor.py) decoded any 20-plus character run of the base64 alphabet with errors="ignore", which discarded exactly the invalid bytes that would have revealed a decode as garbage rather than genuine content; an ordinary REST path or identifier can easily be 20-plus base64-alphabet characters, so this silently corrupted content before any pattern ever saw it, measured at 55 of 69 realistic strings, including S3 object keys, git SHAs, session tokens, content-hash filenames, and GCP's computeMetadata/v1/instance/ path, which decoded to mojibake. A corrupted string cannot match a signature, so this was a detection hole, not a cosmetic one. Decoding now requires a strict UTF-8 decode (which raises, rather than silently discarding bytes, on invalid input) and a minimum printable-ASCII ratio (_PRINTABLE_ASCII_RATIO_THRESHOLD, settled at 0.5 after measurement showed the strict UTF-8 decode alone did nearly all the work), and a token that fails either check is left exactly as it was rather than replaced with the garbage decode; corruption on the same 69-string sample drops to 3, all JWTs, which decode to their own JSON claims, so content injected into a claim stays scannable, and recall on a 47-payload base64-wrapped attack corpus is unchanged. A genuinely undecodable byte is no longer an automatic reject: a lossy decode (errors="replace") is attempted, and accepted or rejected by how much of the result it corrupts (_MAX_REPLACEMENT_CHAR_RATIO = 0.2, a share of the decoded text) rather than a fixed count of replacement characters, so a short payload carrying one bad byte and a long payload carrying several are judged by the same standard rather than the long payload being rejected outright once it crosses a fixed count; this closes a gate an attacker could otherwise use deliberately, padding a payload with enough invalid UTF-8 bytes to guarantee it stays hidden as opaque base64, regardless of how much of the surrounding content was perfectly valid. Hex literals (0x7f...) are exempted from base64 decoding by their 0x prefix specifically; a token that merely looks like hex (an even-length run of 0-9a-f with no 0x prefix) is not exempted, since base64's own alphabet overlaps the hex-safe character set closely enough that an attacker could otherwise choose plaintext whose encoding happens to land in it and skip decoding entirely.
  • Every regex pattern in SusPatternsManager is compiled with re.IGNORECASE | re.MULTILINE (guard_core/handlers/suspatterns_handler.py, both the PatternCompiler-backed path and the legacy no-compiler path), so a pattern anchored with a bare ^/$ does not anchor to the start/end of the whole scanned content, it anchors to the start/end of any line within it. 25 patterns across recon, cms_probing, sqli, cmd_injection, and ssrf used a bare ^/$ this way, so a benign multi-line document containing one line that happened to match a pattern's keyword tripped it regardless of the rest of the document. Verified against the pre-fix code: an internal routes list containing a line reading exactly /version was reported as recon; a workspace role list containing a line reading exactly administrator was reported as cms_probing; a migration note ending a line in ORDER BY 2 was reported as sqli; a shell-usage example ending a line in echo 'debug' # was also reported as sqli; and a deployment-script excerpt containing the line sh -x deploy.sh was reported as cmd_injection. Anchoring is now decided per pattern instead of applied uniformly: a pattern whose content is genuinely expected to be a path (sensitive_file, and most of recon/cms_probing's single-token patterns) is anchored to the whole string (\A...\Z); a pattern that needs to fire on a probe appearing inside a larger document instead requires an explicit scheme://host/ prefix (cms_probing's wp-admin/administrator/xmlrpc pattern, so a URL mentioned in prose is still caught while a bare mention of "backing up the .htaccess file" is not) or a narrower context-sensitive alternative (sqli's ORDER BY/comment patterns now also match immediately after a =, ?, or & even mid-body; cmd_injection gained a pattern for a shell invocation with the -c flag preceded by a newline). This preserves the embedded-attack detection a uniform whole-string anchor would otherwise have removed for content that is, in practice, never nothing but the attack payload -- verified for command injection, SQL injection, and CMS probing embedded inside a larger request body. The two ssrf patterns that also use bare ^/$ are left as they are: their adjacent \s alternative already consumes a newline as a boundary regardless of the MULTILINE flag, so anchoring them would have been a no-op.
  • Separately, the recon category's management/system/version/config_dump/credentials probe pattern matched only a single top-level path segment (/management), so the same probe nested under an application prefix (/app/management/health, /v2/system/version) went undetected. The pattern now matches the same keywords at any depth under a genuine absolute path, while still requiring the path to actually start with /, so a home-directory reference such as ~/.aws/credentials (a credentials-exposure concern, not a reconnaissance probe) is not swept into the wider match.
  • guard-core's ldap category had no pattern for a filter-injection payload that opens with a wildcard rather than a leading (&/(| conjunction: cn=*)(uid=* and *)(password=*), both classic LDAP authentication-bypass shapes, went undetected. A new pattern requires the literal *)( to be immediately followed by an attribute name and = (\*\)\(\s*[a-zA-Z][\w-]*\s*=), which matches both payloads while leaving ordinary prose that merely contains the same three characters in sequence -- an arithmetic aside (total = a*)(b+c)) or a footnote marker (See appendix A*)(footnote 3) for details) -- unmatched, since neither is followed by an identifier and =.

Behaviour changes

  • SecurityConfig.global_behavior_rules is now tuple[BehaviorRuleConfig, ...] instead of list[BehaviorRuleConfig] (see Fixed, above). Code that previously called .append()/.extend()/.insert() on it, or assigned to a slice, now gets an immediate AttributeError/TypeError instead of a silently-unvalidated mutation; replace an in-place .append() with a whole-field reassignment, config.global_behavior_rules = (*config.global_behavior_rules, new_rule), which is validated the same way construction is.
  • Breaking: nine more SecurityConfig fields change type (see Fixed, above). whitelist: tuple[str, ...] | None, blacklist: tuple[str, ...], trusted_proxies: tuple[str, ...] (were list[str]); enabled_detection_categories, muted_event_types, muted_metric_types, muted_check_logs: frozenset[str] (were set[str]); threat_ban_config: types.MappingProxyType[str, ThreatBanConfig] (was dict[str, ThreatBanConfig]); block_cloud_providers: frozenset[str] | None (was set[str] | None). Code that mutates one of these in place -- config.whitelist.append(...), config.muted_event_types.add(...), config.threat_ban_config["xss"] = ... -- now raises AttributeError/TypeError instead of silently mutating an unvalidated collection. Migration: reassign the whole field instead, which is validated the same way construction is -- config.whitelist = [*config.whitelist, "10.0.0.5"], config.muted_event_types = config.muted_event_types | {"dynamic_rule_violation"}, config.threat_ban_config = {**config.threat_ban_config, "xss": ThreatBanConfig(threshold=3, duration=3600)}. A plain list/set/dict is still accepted on reassignment (and at construction) and coerced to the immutable type; only in-place mutation of the field's current value is closed. block_cloud_providers additionally changes behavior independent of its type: an unrecognized provider name that was previously dropped silently now raises ValueError, at both construction and reassignment; a deployment relying on the old silent-drop to tolerate a stale or misspelled provider name must fix the name.
  • config.blocked_countries/config.whitelist_countries can no longer be reassigned (or set via model_copy) into a state with no way to resolve a country, i.e. no geo_ip_handler and no ipinfo_token (see Fixed, above). A deployment that reassigns these fields at runtime (for example from DynamicRuleManager) without a geo_ip_handler already configured will now get a ValueError where it previously got silence and an inert country check; configure geo_ip_handler (or the deprecated ipinfo_token) up front, even before any country rule is set, to keep a runtime-only country-rule flow working. This mirrors the identical construction-time requirement validate_geo_ip_handler_exists already enforced.
  • Identity-block escalation (route/global IP restrictions, ip_blocked, and blocked user-agents) no longer contributes to auto_ban_threshold/threat_ban_config on its own; it only does so when the same request is also flagged by penetration detection, and the categories it counts toward threat_ban_config are the real detected threat categories, not a label describing the identity-block reason. Deployments that relied on repeated identity-only blocks (e.g. a whole blocked country) eventually producing a ban should configure that country/IP range directly in blocked_countries/blacklist instead, since a ban is no longer a side effect of being blocked often.
  • request.state.is_whitelisted now reflects only the outcome of the most recently evaluated global whitelist check for the request: it is left at its unset (falsy) default until _check_global_ip_restrictions actually runs, and is never set from a route-level ip_blacklist/ip_whitelist decision, which does not evaluate the global whitelist at all. Any direct consumer of request.state.is_whitelisted (as opposed to the checks that already read it through getattr(..., False)) should use the same default-False access pattern.
  • _increment_suspicious_counts is a plain def guarded by a threading.Lock in both the async and sync trees (not async def); any direct caller must call it synchronously, without await.
  • exclude_paths no longer bypasses the security pipeline entirely. BypassHandler.handle_passthrough now marks a matched request guard_exclusion_scoped on request.state and returns None instead of calling call_next directly, so the request still reaches SecurityCheckPipeline.execute. There, only the route_config, ip_security, and rate_limit checks run for an exclusion-scoped request (SecurityCheck.enforced_on_excluded_paths, see above); every other check, including suspicious_activity (payload detection), is skipped, so an excluded path is still cheap. Concretely: an already-banned IP is still blocked by IpSecurityCheck._check_banned_ip on an excluded path, a statically blacklisted or whitelisted IP, a blocked country, or a blocked cloud provider is likewise still enforced by IpSecurityCheck._check_global_ip_restrictions (see above), and rate limiting is still enforced, but detection itself does not run against an excluded path, and blocking on it never triggers escalate_identity_violation's detection-based categorisation either. BehavioralProcessor treats an exclusion-scoped request as having no behavior tracker, so it is never sampled for usage/frequency/return-pattern rules and cannot trip a behavioral auto-ban (guard_core/handlers/behavior_handler.py's ban_ip(..., "behavioral_violation")), no matter how many times the excluded path is hit. This closes the gap where a health-check endpoint's fixed one-IP, fixed-interval traffic pattern was exactly what a frequency behavioral rule looks for: previously, an excluded liveness probe could earn itself a behavioral ban and start failing its own orchestrator's health check. Passive mode is unaffected (it never blocks, excluded path or not); non-excluded requests are unaffected, since the new gate only activates when guard_exclusion_scoped is set. Applications that relied on exclude_paths making a path invisible to a standing IP ban, a static blacklist/whitelist/country/cloud restriction, or rate limiting should reconsider that path's inclusion in exclude_paths now that all of them are enforced there.
  • Response-body-reading return_pattern rules (json:, regex:, bare-substring) are opt-in: behavior_scan_response_body defaults to False, so upgrading to this release reads no additional bytes and matches no additional patterns until it is turned on. A deployment that already configured such a rule in global_behavior_rules will now fail to construct its SecurityConfig until it either sets behavior_scan_response_body=True or replaces the rule with a status: pattern; the same shape via @security.return_monitor()/@security.behavior_analysis() now raises the identical ValueError at decoration time. The removed hasattr(response, "body") codepath (see above) never matched anything for a genuinely streaming response, whose .body raises when read before the stream is drained -- for that case, no previously-working behaviour is lost by any of this. It is not true for an ordinary, non-streaming response, whose .body is a plain, non-raising, already-materialized attribute: that shape matched correctly under the removed hasattr/.body codepath, and does not match under this release -- opt-in flag on or not -- until the adapter also implements the new BoundedResponseBodyReader.read_body_prefix capability. This is a lockstep-upgrade requirement across the ecosystem. guard-core, fastapi-guard, flaskapi-guard, and djapi-guard are separate repositories; every adapter pins guard-core with no version constraint (see the automatic-version-reporting entry above, added for exactly this reason). Upgrading guard-core alone, without also upgrading the adapter to a release that implements BoundedResponseBodyReader, silently drops every return_pattern body rule for that adapter -- even with behavior_scan_response_body=True explicitly set -- with no error and no signal beyond the pre-existing throttled could-not-evaluate log line. status: patterns, which read only response.status_code and never touch the body, are unaffected in every case, streaming or not, upgraded adapter or not.
  • In the ASYNC guard_core tree, any adapter call guard-core makes to read a request or response body -- BoundedBodyReader.read_body_prefix, BoundedResponseBodyReader.read_body_prefix, or the plain GuardRequest.body -- that previously could hang indefinitely on a stalled adapter now fails closed (treated the same as a raising reader) after SecurityConfig.body_read_timeout (default 3.0 seconds). The SYNC tree does not: SyncGuardRequest.body, SyncBoundedBodyReader.read_body_prefix, and SyncBoundedResponseBodyReader.read_body_prefix block the calling thread for as long as the adapter takes and body_read_timeout has no effect there; bound a stalled sync adapter read with the WSGI server's own request timeout instead (gunicorn --timeout, uWSGI harakiri).

Documentation

  • detection_max_body_inspect_bytes's field description, the BoundedBodyReader/SyncBoundedBodyReader protocol docstrings, and docs/api/protocols.md / docs/configuration/detection-tuning.md state plainly that bounded body inspection only ever scans the leading detection_max_body_inspect_bytes bytes of the body: a payload padded past that offset, or a signature split across the boundary, is not detected. This is an inherent tradeoff of bounded-memory scanning, not a defect, and no wording implying parity with full-body scanning is used.
  • The BoundedBodyReader/SyncBoundedBodyReader docstrings spell out that the memory bound is adapter-cooperative only: guard-core's prefix[:max_bytes] slice trims what read_body_prefix already returned, but cannot stop an implementation from buffering more than max_bytes internally before returning it. Implementations must not buffer more than max_bytes while producing the prefix; guard-core has no way to enforce this from the caller side. See GHSA-xv6g-49vj-7w9c.
  • docs/api/protocols.md, docs/configuration/security-config.md, docs/api/behavior-rules.md, docs/api/models.md, and docs/internals/behavioral.md document the BoundedResponseBodyReader/SyncBoundedResponseBodyReader protocol, the behavior_scan_response_body/behavior_max_response_body_inspect_bytes/body_read_timeout fields, the could-not-evaluate outcome and its throttled logging, and the streaming/DoS reasoning behind the cap (guard-core bounds what it retains, not what the endpoint produces; a streaming response must stay streaming to the client after inspection). docs/api/protocols.md's GuardResponse.body row, which stated it was "used by behavioral return pattern matching", is corrected: it no longer is, for exactly the reason described above. docs/configuration/security-config.md and docs/configuration/detection-tuning.md now state plainly that body_read_timeout bounds the async guard_core tree only, and that a sync deployment must bound a stalled adapter read with its own WSGI server's request timeout instead -- no wording in either page implies guard-core bounds a sync adapter read. Both this file and docs/release-notes.md call out, in bold, that upgrading guard-core alone does not restore a previously-working return_pattern body rule for a non-streaming response until the adapter also ships BoundedResponseBodyReader support, naming fastapi-guard, flaskapi-guard, and djapi-guard explicitly as the lockstep-upgrade requirement this is.
  • docs/api/ban-config.md documents a known limitation in normalize_url_path: a path segment of ..; (a traversal segment carrying a servlet-style matrix parameter) is not recognised as .. and is kept literal, so /static/..;/etc/passwd normalises to itself and path_is_excluded reports it as excluded when /static is configured in exclude_paths. This is deliberately not fixed: Flask, Starlette/FastAPI, Django, and nginx all route on ; literally and do not strip it, so the path guard-core evaluates already matches what every framework guard-core ships an adapter for actually resolves; the gap is only reachable behind a Java-servlet-style component that strips ;params before final routing, which no supported adapter introduces. Stripping ;... from every segment to close it would have broken legitimate matrix-parameter paths (/orders;customer=42/items, valid under RFC 3986), which normalize_url_path preserves literally today and is now covered by tests documenting both the limitation and that legitimate matrix parameters survive normalisation unmodified.
  • docs/internals/api-surface-audit.md carried a per-field Line column against guard_core/models.py that had gone stale (drifted out of sync with the field it named) more than once as fields were inserted above it in prior updates to this same audit. The column is removed; the table is keyed on field name only, which does not drift, with a grep -n one-liner given for anyone who wants a field's current line. While re-verifying the table against source, two fields present in SecurityConfig but missing from the table (detection_anomaly_emission_cooldown, detection_min_samples_for_anomaly) are now itemized; the table lists all 115 fields the totals line already claimed, and the detection domain subtotal is corrected from 16 to 18 to match.
  • docs/api/models.md, docs/api/behavior-rules.md, docs/configuration/security-config.md, and docs/internals/api-surface-audit.md are corrected: global_behavior_rules and the nine fields above no longer show their pre-3.12.0 list/set/dict types, block_cloud_providers's validator entry no longer says it "silently filters", and validate_geo_ip_handler_exists's entry now notes it also runs on reassignment and model_copy.
  • SecurityConfig.body_read_timeout's field description said the SYNC tree "calls the adapter's read directly and does not use this value at all" because "a blocking call cannot be cancelled from the outside without the thread-pool machinery guard-core removed". Both claims are now stale: the sync tree bounds a read by running it on its own daemon thread and joining that thread with body_read_timeout (budgeted by the new sync_body_read_max_concurrent), so the field is honoured in both trees, just through a join-timeout rather than a true cancellation (the thread itself keeps running until the adapter's call returns; only the caller stops waiting for it). The field description, docs/api/models.md, docs/configuration/security-config.md, and docs/configuration/detection-tuning.md are corrected to say so.
  • docs/internals/detection-engine.md said extract_attack_regions() scans for "21 attack indicator patterns"; four more were added to ContentPreprocessor.attack_indicators alongside the fixes above (the shell metacharacters `, \$\(, and [;&|], so truncation past max_content_length no longer drops the characters a cmd_injection signature needs, plus a bare dotted-quad indicator so an IPv4 address inside a truncated attack region is preserved), and the count is corrected to 25.

v3.11.1 (2026-08-10)

Azure IP-range fetch failed on slow egress and anomaly detection over-fired on low-traffic apps (v3.11.1)

Fixed

  • fetch_azure_ip_ranges was the only cloud provider fetched by scraping Microsoft's HTML download page with a regex and then downloading the dated ServiceTags JSON under a hard 10-second total timeout. The other five providers hit direct JSON endpoints with small payloads, so only Azure produced a recurring Failed to fetch Azure IP ranges error, on every refresh, for deployments whose egress to download.microsoft.com could not complete the 4.7 MB download within 10 seconds, or whose region received a details page whose markup the regex did not match. The JSON download timeout is raised to 30 seconds (the other five providers keep their 10-second direct-endpoint timeout), the download is retried up to three times with a 2-second backoff on transient errors, and URL discovery now falls back to a bare-URL search constrained to ServiceTags files when the href-wrapped form is not found, covering pages that embed the link in JavaScript or data attributes without matching unrelated .json links on the page; both the href and fallback forms preserve a trailing query string. A failed fetch still resolves to an empty range set, so it cannot cause false blocking of legitimate Azure IPs; the change is about reliability and log noise, not enforcement.
  • In active mode, a request blocked by an identity check (IP security country/blocklist/cloud or route allowlist, or a blocked user-agent) short-circuited the pipeline before SuspiciousActivityCheck ran, so a probe that was also a penetration attempt never incremented suspicious_request_counts, never escalated to a persistent ban, and received per-request 403s indefinitely with no ban_ip and the threat intel lost. The three identity-blocking branches now call a shared side-effect-only escalate_suspicious_if_threat helper before the 403 return, mirroring SuspiciousActivityCheck's per-category increment and per-category and flat ban logic exactly. The ban persists via Redis and enforces on the next request through the existing top-of-pipeline banned-IP check; passive mode is unaffected because it already lets SuspiciousActivityCheck run. The helper wraps its body in a try/except so a ban_ip failure (for example auto_ban_duration exceeding the local-cache cap with Redis unavailable) is logged and swallowed rather than losing the caller's already-decided 403.
  • _body_exceeds_inspection_cap, the gate detect_penetration_attempt uses to honour detection_max_body_inspect_bytes (added in v3.2.0), returned False when Content-Length was missing or malformed, so a request sent with Transfer-Encoding: chunked (or a syntactically invalid Content-Length) bypassed the body-inspection cap entirely and triggered an unbounded request.body() read, buffering the full request body into memory on the detection hot path and exhausting server memory under a crafted request. The gate now returns True (fail-closed) when Content-Length is absent or unparseable, so the body is skipped rather than buffered in full, matching the cap's original intent. The same fail-closed cap gate was added to the honeypot validator in guard_core/decorators/advanced.py (the @guard.honeypot_detection([...]) path), which had the same unbounded body-read class of bug on its POST/PUT/PATCH validation branch; both the async and sync trees are fixed. See GHSA-xv6g-49vj-7w9c.

Added

  • Two new SecurityConfig fields make the statistical-anomaly detector tunable: detection_anomaly_emission_cooldown (default 60.0, bounds 1.0 to 3600.0) sets the minimum seconds between anomaly events for the same pattern, and detection_min_samples_for_anomaly (default 30, bounds 10 to 1000) sets the minimum samples recorded for a pattern before statistical-anomaly detection engages. anomaly_emission_cooldown was already a PerformanceMonitor constructor parameter but was never wired from SecurityConfig, so it was fixed at 60 seconds; the sample floor was a hardcoded len(recent_times) < 10 check. Both are now passed from config in suspatterns_handler._apply_enhanced_config. Raise either to reduce noise and false fires on low-traffic apps.
  • guard-core now emits a UserWarning when both whitelist_countries and blocked_countries are configured, because blocked_countries is silently inert under a non-empty whitelist_countries (a non-empty whitelist is restrictive, so the blocklist has no effect).

Behaviour changes

  • The default minimum-samples floor for statistical-anomaly detection rises from 10 to 30. A pattern with fewer than 30 recorded samples no longer emits pattern_anomaly_statistical_anomaly events until 30 samples accumulate. This is the intended effect: the old 10-sample floor combined with high variance on low-traffic apps produced a flood of false anomalies. Operators who relied on the old floor can restore it with detection_min_samples_for_anomaly=10.

v3.11.0 (2026-08-09)

Ten AgentConfig settings were unreachable, on_error never forwarded, and unknown config keys now warn (v3.11.0)

Added

  • Ten new optional SecurityConfig fields expose AgentConfig settings that previously had no counterpart of any kind: agent_high_watermark_ratio, agent_max_concurrent_flushes, agent_buffer_overflow_policy, agent_backoff_factor, agent_sensitive_headers, agent_max_payload_size, agent_compression_enabled, agent_compression_threshold, agent_install_id, agent_payload_signing_secret. to_agent_config() forwarded 13 of AgentConfig's 24 fields; adapters pass SecurityConfig straight through, so no supported configuration path could reach the other ten. Nothing appeared broken because each silently fell back to guard-agent's default. Every new field defaults to None, and to_agent_config() omits None values from the AgentConfig(...) call rather than passing them through, so guard-agent's own defaults remain the single source of truth and cannot drift across the package boundary. Behaviour is unchanged for any configuration that does not set them.
  • SecurityConfig now logs a warning naming any unrecognised constructor keyword. Pydantic's default extra="ignore" silently discarded them, so a typo such as agent_compresion_enabled=False was accepted, had no effect, and produced no diagnostic. A model_validator(mode="before") inspects the raw input before Pydantic drops unknown keys and logs each one through guard_core.models. extra deliberately remains ignore: rejecting unknown keys outright is a breaking change and belongs in 4.0, so this warning is the migration runway for it.

Fixed

  • SecurityConfig.on_error was never forwarded to AgentConfig, so two of the four stages its own field description documents could never fire. The description names agent_init, geoip, transport_send and encryption as the possible stage values, but guard-core emits only geoip; transport_send and encryption are emitted inside guard-agent, which never received the hook. The callback is now forwarded under the same omit-if-None rule as the ten fields above, with no separate agent_on_error field, since one hook receiving all four stages is the documented design. guard-core's own consumption of on_error is unchanged.

Behaviour changes

  • Applications that already set SecurityConfig.on_error will begin receiving agent side errors through it, under the transport_send and encryption stages. This was always the documented contract; the hook simply never reached the agent. A callback that assumes it only ever sees geoip should be reviewed before upgrading.

v3.10.0 (2026-08-09)

Config-derived security pipeline: build only the checks a configuration can actually trigger + Accurate IP-block reasons and configurable block-decision log levels (v3.10.0)

Added

  • SecurityCheck.applies_to(config, route_configs) is a new classmethod extension point that lets a check declare, at pipeline-build time, whether the effective configuration can ever make it fire. build_default_pipeline now filters DEFAULT_CHECK_CLASSES through it before instantiating anything, so a deployment only pays for the checks its configuration can actually trigger. The base implementation returns True, so any check that does not override it keeps running unconditionally; elimination is strictly an optimization, never a security decision, and every applies_to implementation returns True on any uncertainty about route configuration. enable_dynamic_rules=True keeps every check whose predicate depends on a flag DynamicRuleManager can mutate at runtime, regardless of every other flag.
  • build_default_pipeline reads the registered per-route decorator configuration through middleware.guard_decorator to decide which route-driven checks are reachable. When no decorator handle is available the route configuration is treated as unknown and every route-driven check is kept, so a middleware that cannot enumerate its routes loses the optimization rather than the protection.
  • import guard_core no longer forces aiohttp, redis, or maxminddb into sys.modules. guard_core/decorators/base.py moved its BehaviorTracker import from module scope into BaseSecurityDecorator.__init__, and guard_core/handlers/__init__.py and guard_core/__init__.py (mirrored by hand into guard_core/sync/__init__.py, which is not generated) replace their eager re-exports with a PEP 562 __getattr__/__dir__ pair over a name-to-module map, so every name resolves lazily on first access instead of at import time. __all__ is unchanged in both modules and every existing from guard_core... import X call site, getattr(), and dir() keeps working. IpSecurityCheck, SuspiciousActivityCheck, CloudProviderCheck, and CloudIpRefreshCheck now import their handler singleton inside __init__ instead of at module scope, so a block phase 1's applies_to eliminates from the pipeline never imports its handler at all.
  • SecurityCheck.requires: ClassVar[tuple[str, ...]] = () names the packaging extra(s) a block needs; CloudProviderCheck and CloudIpRefreshCheck set it to ("cloud",). Three new optional-dependency extras package the same split: redis, cloud (aiohttp + requests), and geo (maxminddb). All three stay in the base dependencies list for the 3.x line as well, so the extras are additive and no existing install is affected; they become exclusive only at 4.0. SecurityConfig gained a model validator that calls importlib.util.find_spec (never a bare import, so the check itself imports nothing) whenever enable_redis, block_cloud_providers, or a geo-IP handler/country rule is configured, and raises a ValueError naming the missing extra's install command (e.g. pip install guard-core[geo]) instead of letting the feature fail later with a raw ImportError.
  • SecurityConfig gains a private, monotonically increasing revision counter (a Pydantic v2 PrivateAttr, so it is absent from model_fields, model_dump(), equality, and the constructor) that an overridden __setattr__ bumps on every attribute assignment. build_default_pipeline now hands SecurityCheckPipeline the SecurityConfig it built from plus a rebuild closure over DEFAULT_CHECK_CLASSES/applies_to; SecurityCheckPipeline.execute() compares the config's current revision against the revision it last built at and only calls the closure -- reassigning self.checks to a freshly filtered list, never mutating the old one in place -- when the two differ, so a config mutated after the pipeline was built is picked up on the next request instead of staying silently stale. When nothing has changed this is one integer comparison per request, not a config fingerprint. SecurityCheckPipeline(checks), the constructor form every adapter and docs/adapters/testing.md use directly, is unaffected: without a config/rebuild_checks argument the pipeline never rebuilds, exactly as before this change.
  • SecurityCheckPipeline._rebuild_if_stale() now also tracks a size signature -- len() (or 0 for None, with no allocation) on blocked_user_agents, block_cloud_providers, and endpoint_rate_limits, the only mutable containers any applies_to predicate reads, and the only shape that matters since each one is consumed as bool(...), never by content -- alongside the revision counter, and rebuilds when either has moved. config.blocked_user_agents.append("badbot") and the like now trigger a rebuild on the next request the same as config.blocked_user_agents = ["badbot"] already did; a size-preserving in-place mutation (replacing an existing list entry, overwriting an existing dict key) leaves the signature unchanged and still does not rebuild. The three watched field names are not a second hardcoded list: each check declares its own container_fields: ClassVar[tuple[str, ...]] next to its applies_to, and factory.WATCHED_CONTAINER_FIELDS is the union of that attribute across DEFAULT_CHECK_CLASSES, computed once at import time, so a future predicate that starts reading a new container is watched as soon as its check class says so. The revision check still runs first and short-circuits straight to a rebuild without computing the signature when it has moved, so the added cost only lands on the case that used to be free: three len() calls and a tuple comparison, not one integer compare, when nothing has changed.
  • The same staleness mechanism now also covers per-route decorator configuration, closing the last gap the config-revision work above left open. RouteConfig (a plain class, not a Pydantic model) gains an overridden __setattr__ that bumps a RouteConfigRevision counter owned by BaseSecurityDecorator, on every attribute assignment made after construction -- a private _initialized flag keeps the constructor's own ~25 field defaults from bumping it, so decorating a route costs nothing on this counter until something actually configures it. _ensure_route_config, the single point where _route_configs gains an entry, bumps the same counter explicitly the moment a route id is first seen, so a route registered after the pipeline was built is observable on its own. build_default_pipeline reads middleware.guard_decorator.route_config_revision into a callable SecurityCheckPipeline folds into its existing staleness comparison as one more integer check, so a stale pipeline is still detected in constant time regardless of how many routes are registered; route_configs is never re-scanned to answer "did anything change." The six route-driven predicates (authentication, custom_validators, referrer, request_size_content, required_headers, time_window) all read their RouteConfig fields as bool(...)/is not None, the same truthiness-only shape SecurityConfig's containers have, so RouteConfig.__setattr__ also wraps the five mutable-container fields those predicates read (custom_validators, require_referrer, allowed_content_types, required_headers, time_restrictions) -- including the empty []/{} the constructor itself assigns -- in a list/dict subclass whose mutating methods bump the counter directly, so route_config.custom_validators.append(...) and route_config.required_headers["X-Api-Key"] = "required" now rebuild the pipeline the same as the equivalent whole-value assignment already did.

Fixed

  • guard_core/sync/__init__.py sourced SecurityDecorator, RouteConfig, BehaviorTracker, and BehaviorRule from the async tree (guard_core.decorators, guard_core.handlers.behavior_handler) instead of their guard_core.sync.* equivalents. A sync adapter (Flask, Django) importing BehaviorTracker or SecurityDecorator from guard_core.sync and calling an async-flavored method such as initialize_redis or initialize_agent got back an un-awaited coroutine instead of a completed call, so Redis-backed behaviour tracking and decorator event dispatch silently never ran. RouteConfig and BehaviorRule are plain data classes with no methods and are byte-identical in both trees, so re-pointing them is a correctness/consistency fix with no behavioural effect of its own. All four now resolve from guard_core.sync.decorators and guard_core.sync.handlers.behavior_handler.
  • SecurityConfig's extras validator required the maxminddb package whenever geo_ip_handler was set at all, not only when the handler in play is guard-core's own IPInfoManager. A user supplying their own GeoIPHandler protocol implementation (an HTTP-backed resolver, for instance) got a ValidationError demanding pip install guard-core[geo] for a dependency their handler never touches, contradicting the whole point of the protocol. The validator now requires maxminddb only when no handler is supplied and country rules are configured, the one case where guard-core itself constructs (or expects to construct, via ipinfo_token) an IPInfoManager, and never when the caller already brought their own implementation.
  • The extras validator's cloud-blocking gate ignored enable_dynamic_rules, unlike CloudProviderCheck.applies_to/CloudIpRefreshCheck.applies_to, which both build their check whenever dynamic rules are enabled even with an empty block_cloud_providers. A deployment with enable_dynamic_rules=True and no static block_cloud_providers passed SecurityConfig validation cleanly and then hit a raw ImportError at pipeline-build time the moment a dynamic rule turned cloud blocking on. guard_core.models.cloud_blocking_enabled() is now the single predicate the validator and both applies_to implementations call, so the two can no longer drift apart.
  • CloudIpRefreshCheck.applies_to/.check() only ever consulted the global SecurityConfig.block_cloud_providers, unlike CloudProviderCheck, which also honours a route-level block_cloud_providers decorator. A deployment that blocked cloud providers only through a route decorator built a pipeline with no cloud_ip_refresh check at all, so cloud_handler's IP ranges for that provider were never refreshed and CloudProviderCheck matched client IPs against stale or empty ranges. CloudIpRefreshCheck now resolves the same provider set CloudProviderCheck checks against, through route_resolver.get_cloud_providers_to_check, for both its applies_to predicate and its check() body.
  • SecurityHeadersManager.initialize_agent() had no caller anywhere in guard-core or in the shipping adapters, unlike every sibling handler's initialize_agent(), which HandlerInitializer.initialize_agent_for_handlers() wires up. self.agent_handler was therefore never set outside tests, so _send_headers_applied_event (security_headers_applied) and _send_csp_violation_event (csp_violation) were permanently unreachable and those two registered event types were never emitted in production. initialize_agent_for_handlers() now wires security_headers_manager the same way as ip_ban_manager and sus_patterns_handler, and both senders now build their event with the EVENT_SECURITY_HEADERS_APPLIED/EVENT_CSP_VIOLATION constants instead of raw string literals so muted_event_types can name them. security_headers_applied fires only on a TTLCache(maxsize=1000, ttl=300) miss keyed by the request path, so the worst case is roughly one event per distinct path per five minutes, bounded by 1000 cached paths, not one per response.
  • The PEP 562 __getattr__ in guard_core/handlers/__init__.py, guard_core/__init__.py, and guard_core/sync/__init__.py (added earlier in this release to stop eager re-exports) only resolved names present in its class/singleton name-to-module map, so import guard_core.handlers followed by attribute access on a submodule name itself, for example guard_core.handlers.ipban_handler rather than the IPBanManager class it defines, raised AttributeError unless something else had already imported that submodule first. Each __getattr__ now falls back to importlib.util.find_spec/importlib.import_module for any name that resolves to a real submodule, so submodule attribute access works again under guard_core, guard_core.handlers, guard_core.sync, and guard_core.sync.handlers, and a genuinely unknown name still raises AttributeError. find_spec only locates the module, it does not execute it, so a submodule whose own import fails for a real reason (a missing optional dependency, for instance) still surfaces that real error instead of being masked as AttributeError, and the fallback runs only on actual attribute access, so a bare import guard_core still keeps aiohttp, maxminddb, redis, guard_agent, and cryptography out of sys.modules.
  • SecurityCheckPipeline._rebuild_if_stale() read the revision and the container-size signature it stamped onto a rebuild from the live SecurityConfig, after the rebuild closure had already returned, instead of from the config state the closure actually built against. A slower caller that started building against older config state, then finished publishing after a faster caller had already published a correct, newer check list, overwrote that newer list with its own stale one and then stamped the stale list with the revision and signature it read off the now-current live config, leaving the pipeline permanently missing a check while its own staleness check reported it up to date. This could not happen in the async tree, where _rebuild_if_stale() contains no await and always runs to completion within a single, uninterruptible coroutine turn, but it was a real, reproducible lost update in the generated sync tree, whose DynamicRuleManager mutates the three watched config fields from a genuine background threading.Thread that can interleave with an in-flight request's rebuild at the OS-thread level. _rebuild_if_stale() now captures the revision, the container signature, and muted_check_logs from config before calling the rebuild closure, and publishes all four together with the rebuilt checks under a threading.Lock scoped to the publish alone, never to the closure call itself; a caller that built from stale state can now only ever stamp the stale revision it captured, so the next call to _rebuild_if_stale() sees the mismatch and rebuilds again instead of believing itself current. Redundant concurrent rebuilding from a similarly-stale read still costs CPU, not correctness, exactly as before, and the steady-state (nothing changed) comparison stays lock-free.
  • SecurityCheckPipeline.execute() called _rebuild_if_stale() above the try/except that wraps each check, so a raising rebuild closure -- most plausibly a check constructor failing during initialization -- propagated straight out of execute(), bypassing _handle_check_error and the configured fail_secure policy entirely; since the failure also left the revision/signature bookkeeping untouched, every subsequent request retried the same rebuild and raised again, permanently. execute() now wraps the call to _rebuild_if_stale() in its own try/except, ahead of and separate from the one already wrapping each check. A rebuild exception is routed through the same fail_secure decision a check exception already gets: under fail_secure=True (the default) it blocks the request with the same 500 Security check failed response a failing check produces, built through the still-valid last-known-good self.checks[0]'s middleware (or re-raised, in the degenerate case of a pipeline with no known-good check at all to build a response through); under fail_secure=False it logs the error and continues the request against the last known-good self.checks, exactly as a fail-open check error does. Neither path touches the revision/signature bookkeeping on failure, so a transient rebuild failure is retried, and recovers, on the very next request instead of wedging the pipeline into raising forever.
  • DynamicRuleManager.stop() never stopped the rule-update loop in the sync tree. scripts/unasync.py translates the async self.update_task.cancel() plus its await into self.update_task.join(timeout=5), and join waits for a thread rather than signalling it, so the while True: in _rule_update_loop had nothing to observe and kept running as an abandoned daemon thread, continuing to poll for dynamic rules for the life of the process. A Flask or Django deployment calling stop() at shutdown or on worker recycle leaked one such thread per cycle, each still issuing rule fetches. Both trees now share a _stop_event that stop() sets before joining, and the loop's inter-poll wait is interruptible, so the loop observes the signal and exits. stop() remains a no-op when the loop was never started and is safe to call more than once.
  • is_ip_allowed's three independent checks (allow/blocklist, country, cloud provider) collapsed into a single bool, so IpSecurityCheck._check_global_ip_restrictions always emitted reason="IP {ip} not in global allowlist/blocklist" with filter_type="global" regardless of which check actually blocked the request. A US-based AWS IP that passed a country whitelist but was blocked by block_cloud_providers was reported as an allow/blocklist failure, hiding the real cause. Added check_ip_access, which returns an IpAccessResult (allowed, reason, cloud_provider, network) that names the real cause: the provider for cloud blocks, the country for country blocks, and the existing message for actual allow/blocklist blocks. is_ip_allowed now delegates to check_ip_access and keeps its exact signature and bool return; filter_type and event_type on the emitted ip_blocked event are unchanged for backward compatibility, only reason (and, for cloud blocks, new cloud_provider/network metadata) is corrected.
  • _log_country_check_result's blocked-country branch logged at a hardcoded WARNING, bypassing config.log_suspicious_level. It now honors log_suspicious_level (default WARNING, so existing deployments see no change), and can be silenced or re-leveled like every other block-decision log.
  • BehaviorTracker._log_passive_mode_action and _execute_active_mode_action (plus _execute_ban_action) logged ban/log/throttle outcomes at a hardcoded WARNING, bypassing config.log_suspicious_level. They now honor it (default WARNING, unchanged by default). The alert action still always logs at CRITICAL, since that severity is the explicit per-rule escalation the caller opted into, not a hardcoded inconsistency.

Behaviour changes

  • The pipeline a middleware builds is now shorter than 17 checks for most configurations, and the check list logged at initialization reflects what actually runs. IpSecurityCheck is never eliminated: it fronts an unconditional ban lookup whose store is writable from behaviour-rule bans and from other processes sharing the same Redis, so no configuration can prove it unreachable.
  • Cold import guard_core dropped from roughly 385ms to roughly 280ms on a warm filesystem (Python 3.10.19), with aiohttp, maxminddb, and redis fully absent from sys.modules after the import. The remaining cost is dominated by pydantic's own plugin-entry-point discovery when the dev-only guard-agent/logfire stack is installed, which is unrelated to this change and is tracked separately.
  • SecurityCheckPipeline's generic "Request blocked by {check.check_name}" summary line moved from a hardcoded INFO to DEBUG. Every check that can block already logs its own detailed, configurable-level line before returning; the generic summary duplicated that at a mismatched, non-configurable level and was the line reported as unhelpful. It is demoted rather than removed so the structured extra={check, path, method} metadata stays available for anyone parsing DEBUG output or the guard_core.core.checks.pipeline logger directly.

Compatibility notes for the 3.10.0 upgrade

  • Mutating SecurityConfig after the middleware has already built its pipeline now takes effect: SecurityConfig bumps a private revision counter on every attribute assignment, and SecurityCheckPipeline.execute() rebuilds its check list -- reapplying applies_to over DEFAULT_CHECK_CLASSES and reassigning self.checks to a new list -- the first time it observes the revision has moved since the build it is running. A default SecurityConfig() with no route decorators builds ["route_config", "ip_security", "rate_limit", "suspicious_activity"]; setting config.blocked_user_agents = ["badbot"] on that same config no longer leaves the existing pipeline stuck at those four checks, the next request through it rebuilds first and blocks with user_agent. SecurityCheckPipeline(checks), the constructor form every adapter and docs/adapters/testing.md use, still never rebuilds on its own, since it holds no config/rebuild_checks reference; adapters that want the rebuild behaviour go through build_default_pipeline. Concurrency: the rebuild always constructs a brand-new list and swaps it into self.checks with a single attribute assignment, and execute()'s for loop captures the list reference once, at the top of the call, so a request already in flight keeps running against the snapshot it started with even if a concurrent request or the dynamic-rules background task bumps the revision and triggers a rebuild mid-flight. _rebuild_if_stale() captures the revision and the container signature from config before calling the rebuild closure and publishes them together with the checks they describe, under a threading.Lock scoped to the publish alone; two callers can still redundantly rebuild from a similarly-stale read, which costs CPU, not correctness, but a caller that built from stale state can now only ever stamp the stale revision it captured, so the pipeline can never believe itself current while actually missing a check, in either the async or the generated sync tree. That residual is now closed for SecurityConfig: SecurityCheckPipeline._rebuild_if_stale() also compares a size signature over blocked_user_agents, block_cloud_providers, and endpoint_rate_limits -- the only mutable containers any applies_to predicate reads -- so config.endpoint_rate_limits["/a"] = (1, 2) and config.blocked_user_agents.append("badbot") now rebuild the pipeline on the next request exactly as the equivalent whole-value assignment already did, with no DynamicRuleManager or enable_dynamic_rules=True escape hatch required. That residual is now closed for RouteConfig too: BaseSecurityDecorator keeps its own RouteConfigRevision counter, bumped by RouteConfig.__setattr__ on every attribute assignment after construction (not during it, so decorating a route touches it zero times for the constructor's own ~25 defaults) and by _ensure_route_config itself the moment a route id is first seen, so a route registered after the pipeline was built is observable independent of whatever the calling decorator sets next. SecurityCheckPipeline folds middleware.guard_decorator.route_config_revision into the same staleness comparison as one more integer check, so the hot path stays O(1) regardless of route count -- it never re-scans _route_configs. In-place container mutation is covered the same way SecurityConfig's is, but through the counter rather than a recomputed signature: RouteConfig.__setattr__ wraps the five mutable-container fields the six route-driven predicates read (custom_validators, require_referrer, allowed_content_types, required_headers, time_restrictions) in a list/dict subclass whose mutating methods bump the counter directly, so route_config.custom_validators.append(...) and route_config.required_headers["X-Api-Key"] = "required" now rebuild the pipeline exactly as the equivalent whole-value assignment already did. RouteConfig.block_cloud_providers, .geo_rate_limits, and .blocked_user_agents are read by cloud_provider/cloud_ip_refresh, rate_limit, and user_agent respectively; each of those three already has a SecurityConfig-level or enable_dynamic_rules escape hatch and none is among the six purely route-driven predicates, so they were out of that unit's scope and stayed covered for whole-value assignment only. That gap is closed too: RouteConfig.__setattr__ now wraps blocked_user_agents in the same tracked-list subclass as the six route-driven list fields, geo_rate_limits in the same tracked-dict subclass, and a new tracked-set subclass -- covering add, discard, remove, pop, clear, update, intersection_update, difference_update, symmetric_difference_update, and the four in-place operators (|=, &=, -=, ^=) -- covers block_cloud_providers. The list and dict subclasses also picked up in-place-operator coverage they were missing before this fix, on the same three previously-covered fields: sort, reverse, __iadd__, __imul__ on lists; __ior__ on dicts -- so route_config.custom_validators += [validator] now bumps the revision too, which it did not before. route_config.block_cloud_providers.add("AWS"), route_config.geo_rate_limits["*"] = (0, 60), and route_config.blocked_user_agents.append("badbot") now rebuild the pipeline on the next request exactly as the equivalent whole-value assignment already did. Every RouteConfig container any applies_to predicate reads is tracked now; the fix lives entirely in the mutating methods, so _is_stale()'s single-integer comparison on the hot path is unchanged.
  • Two telemetry event types that could never fire now do: security_headers_applied and csp_violation. SecurityHeadersManager was never handed an agent handler by HandlerInitializer before this release, so both event senders were unreachable in every shipping adapter; initialize_agent_for_handlers() now wires security_headers_manager the same way it already wires ip_ban_manager and sus_patterns_handler. Anyone using the SaaS agent will start seeing these two event types appear. security_headers_applied only fires on a TTLCache(maxsize=1000, ttl=300) miss keyed by a hash of the request path, so expect roughly one event per distinct path per five minutes, not one per response. Both event types are silenceable through muted_event_types, the same as any other event type.
  • The pydantic-plugin instrumentation mute now runs the first time telemetry actually initialises, through SecurityConfig.to_agent_config() or HandlerInitializer.initialize_agent_integrations(), instead of at import guard_core. This is the change that takes cold import guard_core from roughly 262ms to roughly 1.6ms when guard-agent/logfire are installed. Every construction site inside guard-core goes through get_telemetry_model(name), which mutes and returns the class in one call, so guard-core's own paths are covered as before. A host application that constructs guard-agent's SecurityEvent, SecurityMetric, or EventBatch models through a path that never touches a guard-core SecurityConfig is not covered by guard-core's mute, but guard-agent 2.8.0 and later apply the identical mute from their own __init__, so any such path covers itself simply by importing guard_agent. The gap is therefore limited to hosts pinning guard-agent below 2.8.0 that also build telemetry models outside guard-core.
  • The guard_core.sync re-export fix described above has no impact on the shipping adapters: flaskapi-guard and djangoapi-guard both already import SecurityDecorator, RouteConfig, BehaviorTracker, and BehaviorRule from the deep guard_core.sync.decorators / guard_core.sync.handlers.behavior_handler paths, never from the top-level guard_core.sync facade the fix touches, so a sync-adapter author has nothing to do here.

Docs

  • docs/architecture/telemetry.md claimed that shipping adapters construct SecurityCheckPipeline(checks) without muted_check_logs, so pipeline-level block and error entries were not muted in practice. build_default_pipeline does pass config.muted_check_logs, and the shipping adapters use the factory, so the claim is corrected. The caveat now applies only to an adapter that hand-builds the pipeline without the factory.

Internal

  • make lint-docs and make fix-docs passed repeated -e exclusion flags to pymarkdownlnt, which do not accumulate: only the final exclusion applied and every other one was inert, so the doc-lint gate reported files it was configured to skip. Exclusions now live in [tool.pymarkdown.system] exclude_path in pyproject.toml and the Makefile targets invoke a plain scan.
  • Tests that constructed SecurityConfig(geo_ip_handler=...) without any country rule tripped the validator warning that tells a user their geo handler is unreachable, emitting 104 warnings across the suite. The dead geo wiring is removed from the tests that never used it; the validator is unchanged. The suite runs warning-free.
  • scripts/unasync.py gained a substitution for subdirectory-level conftest imports so shared test fixtures mirror correctly into tests/test_sync/.
  • Tests that patched ip_ban_manager/cloud_handler at the check module's global scope now patch the check instance's own attribute (patch.object(check, "ip_ban_manager")), since the handler is bound once per instance in __init__ rather than imported as a module-level name.
  • pyproject.toml's filterwarnings silenced the 64 DeprecationWarnings the suite's own SecurityConfig(ipinfo_token=...)/SecurityConfig(ipinfo_db_path=...) construction sites raised against warn_deprecated_fields, plus an inert SelectableGroups entry that matched nothing with filters disabled. Both entries are removed; the sites that only needed some geo handler to satisfy blocked_countries/whitelist_countries validation now pass geo_ip_handler=IPInfoManager(...) or a mock instead of the deprecated fields, sites with no country rule at all just dropped the unnecessary field, tests/test_redis/test_redis.py excludes ipinfo_token/ipinfo_db_path from the model_dump() clones it reconstructs configs from, and the two genuine deprecation-behaviour tests (test_geo_ip_handler_deprecated_fallback, test_geo_ip_db_max_age_wiring) keep the deprecated fields under pytest.warns(DeprecationWarning, ...). The suite runs at zero warnings with no filterwarnings configured at all.
  • make vulture passed vulture_whitelist.py on the command line, which overrides [tool.vulture] paths instead of extending it, so the dead-code gate scanned only the whitelist file and never guard_core/ or tests/ for the life of the repo. The target now runs uv run vulture with no path argument, so it picks up the configured paths; the fuller scan reports no findings at the configured min_confidence.
  • Phase 2's two open items are settled: scripts/unasync.py's import aiohttp -> import requests substitution is a plain, position-agnostic string replacement (verified by transforming a sample with the import moved inside a function body: it still compiles), so no new SUB is needed if guard_core/handlers/cloud_handler.py ever moves its module-scope import aiohttp into a function; it isn't moved in this unit because cloud_handler.py is only reached through CloudProviderCheck/CloudIpRefreshCheck's __init__, so the module-level import already only executes when one of those blocks is actually built. guard_core/__init__.py's _mute_pydantic_plugin_instrumentation() stays eager: python -X importtime -c "import guard_core" with guard-agent/logfire installed spends roughly 256ms of a roughly 262ms total importing guard_agent.models, and removing the eager call drops the same import to roughly 5ms. Deferring it correctly (only when SecurityConfig(enable_agent=True) is actually validated, so the mute still lands before any telemetry model is constructed) would recover that cost for agent-disabled processes, but the only call site that can make that guarantee is SecurityConfig's own agent validator in guard_core/models.py, outside this task's declared guard_core/__init__.py scope, and it would also need coordinated updates to tests/test_init_instrumentation.py/tests/test_sync/test_init_instrumentation.py and the shipped guard_core/.agents/skills/guard-core/references/telemetry.md. The measurement is recorded here so a follow-up unit can make that call with the full picture in view.
  • _mute_pydantic_plugin_instrumentation() no longer runs at import guard_core. It lives in its own module, guard_core/_pydantic_plugin_mute.py (added to scripts/unasync.py's SKIP_SRC, so it is shared between the async and sync trees exactly like models.py), and only runs once a configuration actually needs guard_agent: SecurityConfig.to_agent_config() calls it for the enable_agent=True path, and HandlerInitializer.initialize_agent_integrations() calls it for the enable_otel/enable_logfire/enable_enrichment path, which can wire a telemetry-capable CompositeAgentHandler into every consumer with no agent handler at all. The applied flag is set only once all three models are confirmed muted (or guard_agent is confirmed absent), so a model_rebuild failure partway through the three leaves the flag unset and the next call retries all three, instead of leaving the ones after the failure permanently unmuted while the function believes it is done. guard_core._mute_pydantic_plugin_instrumentation keeps resolving through the existing PEP 562 __getattr__ for back-compat. Measured on this branch (Python 3.10.19, warm filesystem, guard-agent/logfire/cryptography installed): import guard_core is roughly 1.5-2ms, down from roughly 262-287ms, and guard_agent/cryptography are absent from sys.modules after a bare import (tests/test_import_cost.py asserts this alongside the existing aiohttp/maxminddb/redis check). The same module owns access to SecurityEvent/SecurityMetric/EventBatch through one accessor, get_telemetry_model(name), which mutes and then returns the class, and every construction site in guard_core/ uses it instead of importing guard_agent directly. tests/test_telemetry_model_access.py and tests/conftest.py combine three checks, and only the third is a proof of the property users actually depend on. The AST scan is a lint on the known ways of reaching guard_agent at edit time: it fails the build if any module outside a two-file allowlist (_pydantic_plugin_mute.py itself, and models.py for AgentConfig alone, not a telemetry model) uses a plain import, an aliased import, a submodule import followed by attribute access, or the importlib.import_module/__import__ indirection builtins to reach guard_agent, and exempts a TYPE_CHECKING-only reference; it cannot catch a dynamically constructed module name, and it only sees source that exists on disk, not source a given run actually executes. _GuardAgentImportFinder, a sys.meta_path finder tests/conftest.py installs for the whole test session via pytest_configure/pytest_sessionfinish, records the calling module for every guard_agent import the interpreter's import machinery actually resolves and fails at session end if any caller outside the same allowlist ever triggered one -- but sys.meta_path finders are consulted only on a sys.modules cache miss, and the allowlisted mute module is normally the first thing in a session to import guard_agent legitimately, so in practice the finder only reliably catches the first importer of guard_agent in a session; every import after that, dynamic or not, is served straight from the cache and invisible to it, and 100% coverage does not close that gap since coverage only says every line ran, not that every guard_agent import went through a cache miss. The third check asserts the outcome instead of the mechanism, which is the property that actually matters: a pytest_sessionfinish assertion in tests/conftest.py reads SecurityEvent/SecurityMetric/EventBatch straight out of sys.modules (never importing them, so the check cannot cause the very import it is testing for) and, only if guard_agent is present in sys.modules at session end, requires all three to carry plugin_settings == {"logfire": {"record": "off"}}. That is indifferent to how the import happened -- cached or not, static or dynamic, before or after the finder was installed -- which is exactly what the first two checks cannot claim. It is still a statement about this run only: with the suite at 100% line and branch coverage that is strong evidence, not a universal guarantee, and a host application that builds SecurityEvent/SecurityMetric/EventBatch through a path that never goes through a guard-core SecurityConfig is outside all three mechanisms and must mute them itself; the shipped skill docs (SKILL.md, references/telemetry.md) state all three limits plainly.
  • check_ip_country and _check_blocked_countries are unchanged (still public/tested directly); a new _resolve_country_verdict helper backs both check_ip_country and the new cloud/country detail checks so there is one country-evaluation code path, not two that can drift.

v3.9.0 (2026-08-03)

Cloud/geo no-Redis block-until-loaded fix, library-skills skill, and internal test/generator debt cleanup (v3.9.0)

Fixed

  • HandlerInitializer.initialize_redis_handlers gated the entire cloud and geo eager-load block behind Redis. A user with lazy_init=False who awaited guard_startup(app) (which routes through SecurityMiddleware.initialize() -> initialize_redis_handlers()) but ran WITHOUT Redis got no cloud/geo load at startup: the method returned immediately, block-until-loaded silently did not hold, and cloud_handler self-fetched lazily on the first is_cloud_ip call, racing the request. The no-Redis branch now eagerly awaits the in-memory cloud load (cloud_handler.refresh(block_cloud_providers)) when block_cloud_providers is set, and eagerly initializes the geo-IP handler in-memory (geo_ip_handler.initialize()) when present, so the configured providers are populated BEFORE the first request. The lazy_init=True no-Redis path is unchanged (it still warns and returns; lazy init is genuinely inert without Redis).

Behaviour changes

  • With lazy_init=False and no Redis, startup now eagerly awaits the cloud-IP fetch (and the geo-IP database load where applicable) instead of returning immediately, so the first request no longer races the fetch. No-Redis users may see a short startup delay while cloud ranges load; this is the intended block-until-loaded semantics that were previously silently skipped.

Added

  • A library-skills skill is now embedded in the package at guard_core/.agents/skills/guard-core/SKILL.md (with reference notes on adapters, config, detection, pipeline, and telemetry), so uvx library-skills --claude discovers guard-core from the installed wheel. Markdown only; no runtime behavior change.

Internal

  • Cleared pre-existing mypy debt across the test suite and fixed scripts/unasync.py so make check-sync parity holds. No behavior change; tests and the generated sync mirror only.

v3.8.1 (2026-08-03)

Stop the inert-lazy_init warning, complete the preempted-header warning's advice, and make global whitelist_countries actually restrict (v3.8.1)

Fixed

  • HandlerInitializer's "lazy_init has no effect without Redis" warning, introduced in v3.8.0, fired whenever Redis was disabled and a cloud-IP or geo-IP path existed, regardless of whether lazy_init was actually enabled. Because SecurityConfig.lazy_init defaults to True, a user who never opted into lazy init and never uses Redis still saw the warning on every startup. The check now returns early unless lazy_init is actually True, so it only warns the user who genuinely asked for lazy init and won't get it.
  • The preempted-forwarded-header warning introduced in v3.7.1 told users to disable the app server's forwarded-header handling (uvicorn --no-proxy-headers, proxy_headers=False) and declare trusted_proxies, but omitted trust_x_forwarded_proto. A user who followed the first step alone, the obvious reading, broke HTTPS detection on a TLS-terminating host (Render, Heroku, a CDN): with proxy_headers off the server stops forwarding the URL scheme, and https_enforcement only honours X-Forwarded-Proto when trusted_proxies is populated and trust_x_forwarded_proto=True, so under enforce_https=True it saw plain HTTP and redirect-looped. The warning now names all three settings and calls out the redirect-loop risk.
  • whitelist_countries at the global SecurityConfig level was exemption-only: a country in neither whitelist_countries nor blocked_countries was allowed, and with no blocked_countries set it was a complete no-op. This contradicted the field's documented meaning, the route-level allow_countries decorator (which already restricted), and the sibling IP whitelist field (which already restricted). The global country check now treats a non-empty whitelist_countries as a true allow-list: only listed countries pass, an unresolved country is blocked (fail-closed, matching allow_countries), and an explicit match overrides blocked_countries.

Behaviour changes

  • The inert-lazy_init warning now fires only when lazy_init=True; with the default or lazy_init=False it is silent.
  • Only the preempted-forwarded-header warning's message text changed; extract_client_ip returns the same value in every case and the warning still fires at most once per process.
  • A non-empty whitelist_countries now restricts traffic to the listed countries. Previously it only exempted listed countries from blocked_countries and otherwise allowed everything, so a user who set whitelist_countries=["US","CA"] expecting "only US/CA" got default-allow. Non-listed countries are now blocked; users who combined whitelist_countries with blocked_countries expecting exemption-only semantics will see non-listed countries blocked too. This aligns the field with its name and docs.

v3.8.0 (2026-07-31)

Stop the anomaly telemetry burst and two recon false positives (v3.8.0)

Added

  • CloudManager.is_cloud_ip() now logs a rate-limited WARNING (at most once every 300 seconds per provider) the first time it evaluates a provider whose IP ranges are not yet populated, previously this failed open in total silence, with no signal that the check was a no-op. The return value is unchanged in every case.
  • cloud_handler.get_status() and the IPInfoManager instance's get_status() report per-subsystem ready / last_refreshed / entries; IPInfoManager gains last_refreshed and entry_count (reader.metadata().node_count) for parity with CloudManager's existing last_updated / ip_ranges introspection. Adapters expose both combined via their status surface (fastapi-guard: SecurityMiddleware.get_initialization_status() / GET /_guard/status), cheap enough to back a Kubernetes/ALB warmup probe or health endpoint. See Provider Status.
  • HandlerInitializer now warns when lazy_init is configured but has no effect (Redis disabled, so its only consulted branch is unreachable) and when SecurityConfig.geo_ip_handler is set without blocked_countries / whitelist_countries (constructed but never initialized). Both are warnings only; neither raises or changes behaviour.
  • IPInfoManager.get_country()'s uninitialized-reader warning now distinguishes a startup race (never yet attempted) from a permanently failed initialization (already attempted and failed), so the log line reads differently for "still warming up" versus "check the token and network."

Fixed

  • PerformanceMonitor._detect_statistical_anomaly compared abs(z_score) against anomaly_threshold, so a pattern running faster than its own rolling average tripped statistical_anomaly exactly as often as one running slower. A regex finishing early is not an anomaly; only z_score > anomaly_threshold (slower than average) is checked now.
  • Anomaly-event emission had no rate limiting: _check_anomalies sent a pattern_anomaly_* event to the agent handler for every tripping metric, so a single host-wide stall (GC pause, cron job, backup, noisy-neighbour CPU contention) that inflated every tracked pattern's execution time at once produced one event per pattern, all sharing a timestamp. A production customer reported thousands of pattern_anomaly_statistical_anomaly events from a single incident, which also consumed their metered event quota. PerformanceMonitor now takes a new anomaly_emission_cooldown constructor parameter (default 60.0 seconds, clamped 1.0-3600.0) tracked per pattern on PatternStats; once a pattern emits an anomaly event it will not emit another until the cooldown elapses, while a pattern that is genuinely and continuously slow still reports, just at most once per window. The cooldown state lives inside the same PatternStats entry that max_tracked_patterns already evicts, so it cannot accumulate unbounded memory. Callbacks registered via register_anomaly_callback are unaffected by the cooldown and still run on every trip, they execute in-process at no quota cost, and applications may depend on per-execution granularity (for example, a local circuit breaker).
  • The builtin recon regex flagged requests for robots.txt, sitemap.xml, and security.txt as reconnaissance. All three are standards-defined files meant to be fetched publicly, robots.txt is RFC 9309, sitemap.xml is the sitemaps.org protocol and is deliberately submitted to search engines, security.txt is RFC 9116 and exists specifically so security researchers can find it, and every crawler, browser, link-preview fetcher, and mobile app requests them as routine behaviour. A production customer had their own phone flagged as a high-severity threat for fetching /robots.txt from their own site. The three entries are removed from the alternation; readme.txt, README.md, CHANGELOG, pom.xml, build.gradle, appsettings.json, and crossdomain.xml remain, since those are genuine information-disclosure signals rather than standards-defined public files.

Behaviour changes

  • Requests for /robots.txt, /sitemap.xml, and /security.txt no longer match the recon category; the other entries in that pattern are unaffected. Only slower-than-average pattern executions can trip statistical_anomaly, faster ones, previously flagged too, no longer are. pattern_anomaly_* events sent to the agent handler are now rate-limited to at most one per pattern per anomaly_emission_cooldown window (default 60s); this does not change anomaly_callbacks behaviour, timeout/slow_execution detection, or the get_problematic_patterns/get_slow_patterns diagnostics.
  • None from the observability additions above: is_cloud_ip() and check_ip_country() return exactly what they returned before in every case (locked in by new regression tests); the new warnings and get_status() / get_initialization_status() accessors are additive and read-only.

v3.7.1 (2026-07-30)

Detect when the app server has already resolved the client from X-Forwarded-For (v3.7.1)

Added

  • A one-time warning when the connecting IP appears inside its own X-Forwarded-For chain. ASGI/WSGI servers apply forwarded headers before any middleware runs, uvicorn defaults to proxy_headers=True with forwarded_allow_ips="127.0.0.1", and a same-host reverse proxy always connects from loopback, so request.client_host, which extract_client_ip uses for the entire trusted_proxies decision, may already have been rewritten from the header. A genuine proxy appends the address it received the connection from and never lists its own, so the connecting IP turning up among the header's entries means something upstream resolved it first. Two consequences this surfaces: with trusted_proxies unset, documented as "no declared proxy, so X-Forwarded-For is never trusted", the returned address is whatever the client claimed, so a rotating header defeats rate limiting and IP banning entirely; and once the server has pre-resolved the peer it no longer matches a declared proxy, so legitimate traffic trips the spoofing branch and emits spoofing_detected on every request. Verified end to end with trusted_proxies unset at rate_limit=3/60s, one caller rotating X-Forwarded-For: 12 of 12 requests were served under uvicorn's default, versus 3 served and 9 rate-limited with --no-proxy-headers. The remediation is to disable the server's own handling (uvicorn --no-proxy-headers, or proxy_headers=False in uvicorn.run; gunicorn, hypercorn and WSGI servers have equivalent settings) and declare the proxy through trusted_proxies / trusted_proxy_depth so guard-core is the single authority. Same bug class as GHSA-77q8-qmj7-x7pp / CVE-2025-46814, one layer further out.
  • Deployment guidance in docs/internals/ip-management.md and a cross-reference in docs/configuration/security-config.md. Neither guard-core nor its adapters previously mentioned the app server's forwarded-header handling anywhere.

Behaviour changes

  • None. This release is observability only: extract_client_ip returns exactly what it returned before in every case, the existing spoof warning and spoofing_detected event are unchanged, and the new warning is emitted at most once per process. The true socket peer cannot be recovered once the server has overwritten it, so guard-core reports the condition rather than pretending to repair it.

v3.7.0 (2026-07-29)

Opt-in enforcement when an adapter cannot resolve the route (v3.7.0)

Added

  • SecurityConfig.route_resolution_strict (default False). A missing RouteConfig has always meant two different things, the route carries no decorators, or the adapter failed to match the request to its route, and every per-route check treats both as "nothing to enforce". The first is correct and unchanged; the second silently disables the checks the route does declare, which is how GHSA-f2vm-w8gq-h378 turned a route-matching bug in the Starlette adapter into an unauthenticated bypass of @require_auth. Adapters now report a failed match by setting request.state.guard_route_unresolved = True, and with route_resolution_strict=True those requests are logged, emit the new route_unresolved event, and are blocked with 500 (or logged only under passive_mode). See docs/adapters/decorators.md.
  • EVENT_ROUTE_UNRESOLVED (route_unresolved) event type.

Fixed

  • Behavioural rules were inert on any adapter that wires its decorator per request. BehavioralProcessor._behavior_tracker() read the decorator only from BehavioralContext, which adapters snapshot when they construct the middleware, before the application attaches its SecurityDecorator, and rebuild only when an agent, OpenTelemetry, Logfire or enrichment is enabled. On a plain decorator-only setup the tracker resolved to None on every request, so usage_monitor, return_monitor and global_behavior_rules counted nothing and never banned, throttled or alerted. It now falls back to request.state.guard_decorator, the same per-request source RouteConfigResolver already uses. Verified end to end: usage_monitor(max_calls=2, action="ban") previously served six requests with 200, and now bans after the threshold. Existing tests missed this because they construct the processor directly with a tracker already attached.

Behaviour changes

  • None by default. route_resolution_strict defaults to False because guard-core cannot tell a failed match from a request the app simply does not route, so enforcing on every unresolved request would reject those too, with it on, a request to a path the app does not serve returns 500 rather than 404. Enable it where every reachable path is a known route. Adapters that never set guard_route_unresolved are unaffected under either setting.

v3.6.0 (2026-07-28)

SQL comment-terminator detection (v3.6.0)

Behaviour changes

  • Requests carrying a closing quote followed by a SQL comment, admin'--, 1'--, admin'#, admin')--, are now detected as sqli and blocked. Values that previously reached your routes may now be rejected. The match requires the quote and the comment marker to be adjacent (optionally separated by whitespace, ) or ;), and # must end the value, so quoted fragments such as querySelector('#app') and href='#top' are unaffected.

Fixed

  • Closed a SQL-injection detection gap: the authentication-bypass form that closes a string literal and comments out the rest of the statement (WHERE user='admin'--' AND pass='...') passed detection. The tautology variants (' OR '1'='1) were already covered, but no pattern matched a quote followed by a comment terminator. The attack-simulation baseline is unchanged (detection_rate 0.857, fp_rate 0.000 across all benign categories).

v3.5.0 (2026-07-15)

Pipeline factory, decorated-route IP/country enforcement, and detection ReDoS hardening (v3.5.0)

Breaking changes

  • Global IP and country rules now apply on decorated routes. Previously, any route carrying per-route decorator config, even one using only @rate_limit, silently skipped every global IP allowlist/blocklist and country rule; those global rules now always run on decorated routes, so a client excluded by a global whitelist can receive 403 on a decorated route that previously served it. A per-route setting overrides the global gate only for the aspect it explicitly allows, and the IP and country aspects are evaluated independently: a route ip_whitelist match wins over that route's own ip_blacklist (unchanged since v3.2.0) and over the global blacklist, but it does not extend to the country aspect, the route's own country rules and the global blocked_countries still run. Only an actual route whitelist_countries match for the resolved country skips the global country gate. To keep a decorated route reachable by clients outside the global whitelist, give the route its own ip_whitelist.
  • A route-level ip_whitelist match now grants access only, not trust. The matched request still passes through rate limiting, user-agent filtering, cloud-provider blocking, and attack-pattern scanning. Previously a route ip_whitelist match set request.state.is_whitelisted=True, exempting the request from every downstream check, so a route's own @rate_limit was silently a no-op for its whitelisted IPs. Global whitelist membership still confers full trust; a client in both the global whitelist and a route's ip_whitelist is treated as access-only on that route.

Added

  • build_default_pipeline(), one source of truth for the check pipeline. New guard_core.core.checks.build_default_pipeline(middleware) assembles the canonical 17-check pipeline in its defined order. Framework adapters call it instead of hand-listing check classes, so a new engine check reaches every adapter (FastAPI, Flask, Django) without an adapter-side change.
  • Redis resilience settings: redis_socket_connect_timeout (default 2.0s) and redis_socket_timeout (default 2.0s) bound how long any Redis call can hold a request (both must be positive, 0 would mean a non-blocking socket, not "no timeout"; None disables); redis_health_check_interval (default 30s, 0 disables) recycles stale pooled connections; redis_max_connections (default None = redis-py default) caps the pool; redis_retries (default 1, 0 disables) adds client-level retries with exponential backoff on connection/timeout errors. Note the client-level retry can re-send a non-idempotent INCR whose reply was lost after the server committed it, over-counting by one, fail-closed for guard-core's rate-limit counters and self-healing next window.
  • redis_fail_open (bool, default False): when a Redis outage surfaces as a GuardRedisError inside a security check, fail_secure governs by default (the request is blocked). Set True to skip the failing check and let the request through, treating Redis outages as an availability concern distinct from other check failures.
  • log_country_check_level: per-request country verdicts that are not blocks (whitelisted / not-affected) now log at a configurable level (default "INFO", None silences them) via the named guard_core logger instead of the root logger. Blocked-country hits still log at WARNING; no-rules / no-geolocation cases at DEBUG. Penetration-detection hits likewise honour log_suspicious_level (previously a second hardcoded-WARNING root-logger path), and the remaining bare root-logger calls in utils.py / cloud_handler.py moved onto named guard_core loggers. Async and sync mirrors updated identically.

Changed

  • Detection regex matching now uses one shared worker pool. Instead of constructing a thread pool per pattern match, matching uses a single shared executor, and built-in (compile-time-vetted) patterns match directly without the per-match timeout wrapper, only custom and legacy-mode patterns pay that cost. Scan input is now capped to detection_max_content_length before matching in every mode, including legacy/no-preprocessor mode, which previously scanned unbounded content (a thread-pool timeout cannot interrupt a regex already running on the interpreter, so the cap bounds the worst case directly). Detection results are unchanged, the attack-simulation baseline (recall 0.857, false-positive rate 0.0) holds bit-for-bit.
  • add_pattern now returns bool (True registered, False rejected) instead of None, so callers can distinguish a rejected pattern from a registered one.
  • Cloud-provider IP refresh no longer runs on the request path. The request that crosses cloud_ip_refresh_interval schedules a single-flight background refresh (cloud_handler.schedule_refresh) instead of awaiting multi-second provider fetches inline; while one refresh is in flight, further requests are no-ops. The background task runs the middleware's refresh_cloud_ip_ranges(), so adapter overrides stay on the periodic path; the debounce timestamp is restored when scheduling fails so the next request retries instead of waiting a full interval; and the in-memory cloud-IP store now honors the refresh TTL, so non-Redis deployments refetch provider ranges each interval instead of caching them for the process lifetime. Async and sync mirrors updated identically.
  • guard-agent's telemetry models are opted out of pydantic plugin instrumentation at guard_core import: SecurityEvent/SecurityMetric/EventBatch set plugin_settings={"logfire": {"record": "off"}}, so a host app running logfire.instrument_pydantic() no longer emits a span per security event. A model that cannot be force-rebuilt degrades with a logged warning instead of crashing the import.
  • The pipeline handles Redis outages per redis_fail_open: a GuardRedisError escaping a check is either skipped with a warning (redis_fail_open=True) or handed to the standard fail_secure path (default). Async and sync mirrors updated identically.

Fixed

  • Built-in detection patterns rewritten to close a ReDoS. Several patterns, SQL SELECT ... FROM and UNION SELECT NULL, a handful of recon file-extension/source-map/backup patterns, and two XSS patterns, could backtrack super-linearly on crafted input; they now run in linear time, closing a request-triggered ReDoS on the detection path.
  • build_default_pipeline now propagates SecurityConfig.muted_check_logs to the pipeline, so muting a check's block/error logs takes effect.
  • Suspicious-pattern registration now rejects unsafe regexes. add_pattern, used when restoring custom patterns from Redis and when applying dynamic-rule pattern pushes, runs each pattern through the ReDoS safety validator before it reaches the live matcher, and no longer logs "Added" for a pattern it rejects. Unsafe or malformed patterns are logged and skipped instead of compiled into the live matcher.
  • The shared regex executor is now initialized under a lock, closing a first-call race that could leak a worker pool.
  • Custom-pattern safety validation no longer false-rejects safe patterns under scan load. validate_pattern_safety probes now run on a dedicated single-worker validation executor, isolated from the shared scan pool used for live request matching, and elapsed time is measured from when a probe starts executing rather than when it was queued, a busy scan pool can no longer silently drop a pushed dynamic rule or a Redis-restored custom pattern.
  • The shared regex scan pool can no longer be permanently wedged by a slow custom pattern. Four consecutive timed-out submissions now swap in a fresh pool, the stale pool is shut down non-blocking and a warning names the leaked workers, and the counter resets on any successful scan.
  • The custom-pattern timeout heuristic now honours detection_compiler_timeout (falling back to its 2.0s field default in legacy/no-compiler mode) instead of a hardcoded 2.0s, so tuning the timeout actually changes when a custom pattern is flagged, and logged/reported, as timed out.
  • Registering a custom pattern no longer blocks the event loop. add_pattern's ReDoS validation (up to ~1s of probes on Redis restore or dynamic-rule push) is now offloaded to a worker thread in the async API; the sync API is unchanged.
  • Pattern timeout heuristics now use a monotonic clock, so a wall-clock/NTP step can no longer misclassify a match as timed out.
  • dynamic_rule_violation is now a registered, mutable event type. It can be suppressed via muted_event_types; it was emitted by endpoint rate limiting but previously rejected by config validation.
  • Banned-IP blocks are now visible to telemetry. Blocking a banned IP emits an ip_blocked event with filter_type="banned"; repeat requests from already-banned IPs were previously invisible.
  • geo_ip_db_max_age now takes effect. It is passed to the auto-constructed IPInfo handler; the setting was previously silently inert.

The Redis resilience settings, redis_fail_open, log_country_check_level, the non-blocking cloud-IP refresh, and the pydantic-instrumentation opt-out were contributed by @davidsmfreire in #39.

Removed

  • Dead RouteConfig.session_limits attribute, never set by any decorator, never read by any check.

v3.4.0 (2026-07-02)

Body-scan location scoping, recursive/form/multipart detection exclusion, and live detection configuration (v3.4.0)

Added

  • detection_scan_body, location-scoped penetration detection. New SecurityConfig.detection_scan_body (bool, default True), with a per-route override via RouteConfig.detection_scan_body and the detection_exclusion(scan_body=…) decorator argument. When set to False, penetration detection scans the URL path, query parameters, and headers but never reads or matches the request body, removing the entire request-body false-positive class in a single switch, regardless of body shape (JSON, form, multipart), while preserving scanner/recon protection on the URL surface. The default True preserves prior behavior. Async and sync mirrors updated identically.

Changed

  • excluded_detection_body_fields now matches nested, form, and multipart bodies. Previously only top-level JSON keys were excluded, so the allowlist could not reach content nested inside arrays/objects, and non-JSON bodies were scanned as an opaque blob. Excluded field names are now matched at any JSON nesting depth, applied to application/x-www-form-urlencoded field names (after decoding), and applied to multipart/form-data text-part names (file parts are skipped, never scanned). Bodies that are not structured or not parseable still fall back to a whole-body scan. The allowlist is now effective for OpenAI-style {"messages":[{"content": …}]} payloads, HTML form submissions, and small text uploads. Async and sync mirrors updated identically.
  • Detection settings now take effect in production (behavior change). The suspicious-pattern engine is configured from SecurityConfig at middleware startup, so detection_threat_score_threshold, the content preprocessor, and the semantic analyzer now apply to live traffic. Previously the process-global detection singleton was constructed once at import with no config and never reconfigured, so those settings were silently inert outside of tests. As a result, detection now runs in its enhanced mode in production: the default detection_threat_score_threshold is unchanged (1.0), but the content preprocessor (which normalizes and decodes payloads before matching, catching encoded evasion) and the pure-Python semantic analyzer are now active for every request. No new dependencies are required. Review your detection logs after upgrading and tune detection_threat_score_threshold, the excluded_detection_* sets, or detection_scan_body if the tighter matching changes what is flagged. Async and sync mirrors updated identically.

v3.3.0 (2026-07-01)

Detection overhaul, recall 0.42 → 0.86, false positives 0.125 → 0.0, graduated anomaly scoring, and an attack-simulation benchmark harness (v3.3.0)

Added

  • Graduated anomaly scoring. New SecurityConfig.detection_threat_score_threshold (float, default 1.0, ge=0.0, le=10.0): the anomaly score a request must reach before it is flagged as a threat. Detection now accumulates a graduated per-request anomaly score instead of relying on a single binary pattern match. The default threshold of 1.0 reproduces the prior flag-on-any-match behavior, so upgrading is behavior-neutral unless you deliberately raise the threshold (fewer, higher-confidence flags) or lower it (more sensitive). Async and sync mirrors updated identically.
  • Attack-simulation benchmark harness. A reproducible benchmark (make attack-sim) that scores the detector against a labelled corpus of malicious and benign payloads and reports detection (recall) and false-positive rates against a committed baseline.json, plus an AI-coordinated red-team campaign generator with verified attack seeds. Test/CI infrastructure only, no runtime or public API surface.

Changed

  • Detection recall raised from 0.42 to 0.86. Repaired the content preprocessor's comment stripping, SQL block/line comments and several encoded payload forms were not normalized before pattern matching, and expanded coverage across the suspicious-pattern set, so a large class of previously-missed injection and traversal attempts is now caught. Async and sync mirrors updated identically.
  • False-positive rate reduced from 0.125 to 0.0, with recall held. Tightened patterns to require genuine attack context instead of matching benign traffic: SELECT … FROM is now scored by corroboration rather than a bare keyword, and the ORDER BY, DDL, ERB-template, and NoSQL-operator patterns require surrounding attack context. The benign corpus was expanded and the baseline re-measured. Async and sync mirrors updated identically.

Fixed

  • ipinfo_token / ipinfo_db_path deprecation warning no longer fires on None. The DeprecationWarning added in 3.2.0 keyed only on whether the field was passed to the constructor, so a caller forwarding an optional setting, e.g. SecurityConfig(ipinfo_token=settings.ipinfo_token) where the setting may be None, received a spurious warning even when ipinfo was not in use. The warning now fires only when the deprecated field has a non-None value. Async and sync mirrors updated identically.

v3.2.0 (2026-06-23)

Cloud-IP region scoping, IP allow-list correctness, bounded body inspection, async/sync mirror parity, agent-error clarity, deprecation signalling, and documented Protocols (v3.2.0)

Added

  • Observable agent and middleware errors. New SecurityConfig.on_error, one best-effort on_error(stage, exc, context) hook (stageagent_init / geoip / transport_send / encryption) invoked at failure points and guaranteed never to propagate into the request path (a hook that raises is caught and logged). New SecurityConfig.agent_strict (default False): adapters raise at initialization instead of silently degrading to agent-off when an enabled agent cannot be constructed.
  • Region/scope carve-outs for cloud-IP blocking. block_cloud_providers and @block_clouds now accept flat-string region selectors: a bare provider ("GCP") blocks the whole provider unchanged, while a carve-out ("GCP:!us-central1") blocks the provider except that region. Region scoping is derived from the real scope field in GCP's cloud.json and the region field in AWS's ip-ranges.json (no hardcoded region lists); Azure remains provider-level. The networkregion index is built at refresh/index time so per-request is_cloud_ip stays O(current), a single dict lookup on a match. block_cloud_providers is now typed set[str] (was set[CloudProvider]); existing bare-provider configs are unchanged. Region data survives Redis via inline "network|region" encoding under versioned keys (cloud_ip_v2 / cloud_ranges_v2), pre-upgrade cache entries are ignored and refetched within cloud_ip_refresh_interval, and older replicas never read the new value shape during a rolling deploy. Async and sync mirrors updated identically.

  • Bounded request-body inspection. New SecurityConfig.detection_max_body_inspect_bytes (default 262144 / 256 KiB; ge=1024, le=10485760). detect_penetration_attempt now skips reading and scanning the body when the request's Content-Length exceeds the cap, so a large body (e.g. ~300MB on a high-traffic proxy) is no longer fully buffered and decoded into memory on the hot path. This bounds the read itself, unlike detection_max_content_length, which only truncates inside the regex preprocessor after the body is already in memory. Async and sync mirrors updated identically.

Fixed

  • IP allow-list is now reliably honored. An explicit whitelist match overrides the blacklist (dynamic IP bans, evaluated earlier, still win), applied consistently to the global path (is_ip_allowed) and the route path (check_route_ip_access), previously the blacklist was evaluated first, so a whitelisted IP that also fell inside a blacklisted CIDR was blocked. Bare-IP matching now uses parsed ip_address() equality instead of raw string comparison, so IPv6 compact and expanded forms (::1 vs 0:0:0:0:0:0:0:1) match correctly. The global and route-level matchers share one primitive (utils._ip_in_list) so they cannot drift, and precedence is documented in the SecurityConfig.whitelist / blacklist field descriptions. Sync mirror updated identically.
  • X-Forwarded-For client-IP extraction honors trusted_proxy_depth. _extract_from_forwarded_header previously returned the leftmost (client-spoofable) X-Forwarded-For entry regardless of trusted_proxy_depth; it now returns the trusted_proxy_depth-th entry from the right, so a client prepending fake entries can no longer defeat the IP allow-list behind trusted proxies.
  • Restored async/sync mirror parity and enforced it in CI. The generated guard_core.sync mirror had drifted from its async source (make check-sync was failing across ~33 files). Repaired the unasync generator (bare asyncio.Lock annotations, from guard_core.handlers import … package imports, the CloudIpStoreFactory alias, the decorators logger string, and AsyncMock await_count/await_args assertions) and excluded the genuinely hand-maintained sync files, the RateLimitManager threading lock and its tests, that the regex transform cannot reproduce. A new check-sync pre-commit hook now fails CI on any future async/sync drift.
  • Clear, actionable error when the agent package is missing. SecurityConfig.to_agent_config() now raises AgentPackageNotInstalledError (naming the package and install command) instead of returning an ambiguous None, so a missing guard-agent can no longer be misreported as an "invalid config / check agent_api_key" error by adapters.
  • GeoIP lookups no longer fail silently. SecurityEventBus._lookup_country now logs at warning and fires the on_error hook (stage="geoip") instead of swallowing the exception with a bare except Exception: return None.
  • Replaced the deprecated redis setex call with set(..., ex=ttl) in both the async and sync Redis handlers, clearing the redis-py DeprecationWarning.

Deprecated

  • ipinfo_token and ipinfo_db_path now signal deprecation at runtime. Both fields, long described as deprecated in favour of a custom geo_ip_handler, now emit a DeprecationWarning when explicitly set, raised once at construction from a model_validator keyed on model_fields_set (so it never fires on the default value or on internal access). Both keep working unchanged; removal is targeted for a future major release. Migrate by passing any GeoIPHandler as geo_ip_handler.

Documentation

  • Documented the integrator-facing Protocols. Every public Protocol extension point, RedisHandlerProtocol, AgentHandlerProtocol, CloudIpStoreProtocol, GeoIPHandler, GuardRequest, GuardResponse/GuardResponseFactory, GuardMiddlewareProtocol (and their Sync* mirrors), now carries a WHAT/WHEN/HOW class docstring plus a per-method contract docstring covering return-value semantics (None-on-miss, None vs empty set, bool success, TTL units). Docstrings only; no signature or behavior change.
  • Added an API-surface audit (docs/internals/api-surface-audit.md): an inventory of all SecurityConfig fields and the package exports, grouped by domain with a keep/deprecate/group/remove recommendation per item, the ipinfo_* deprecation path, and the guard_core ↔ fastapi-guard export single-source-of-truth.

v3.1.1 (2026-05-27)

Agent endpoint default + PEP 639 license metadata (v3.1.1)

  • Changed, SecurityConfig.agent_endpoint now defaults to https://api.guard-core.com (previously https://api.fastapi-guard.com), aligning the Guard Agent SaaS endpoint with the guard-core brand. Set SecurityConfig(agent_endpoint=...) to target a different host.
  • Packaging, Migrated license metadata to PEP 639: license = "MIT" (SPDX expression) plus license-files = ["LICENSE"], and dropped the deprecated License :: OSI Approved :: MIT License classifier. Clears the setuptools project.license-table and license-classifier deprecation warnings.
  • Build, Removed the unused setup.py; the release workflow now builds via python -m build (hatchling backend) instead of python setup.py sdist bdist_wheel, eliminating the setup.py install is deprecated warning.

v3.1.0 (2026-05-15)

Production reliability + ergonomics: NOSCRIPT recovery, lazy_init by default, cloud-IP store factory (v3.1.0)

Fixed

  • Recover from Redis NOSCRIPT silently degrading rate limiting. RateLimitManager._get_redis_request_count previously caught RedisError and fell through to in-memory counters when EVALSHA raised NoScriptError (after SCRIPT FLUSH, restart, or failover to a node without our cached SHA), leaving every replica desynchronized. Now catches NoScriptError specifically inside the connection block, reloads the Lua script via script_load, and retries once. Sync mirror updated identically.
  • Drop log levels for routine private-IP and missing-geo noise. "IP not geolocated" and "no countries blocked or whitelisted" → DEBUG. "Potential IP spoof attempt" → DEBUG for private/loopback/link-local source IPs; WARNING for public sources.
  • RedisCloudIpStore default key_prefix no longer duplicates the guard: segment. Default changed from "guard:cloud_ip" to "cloud_ip" because RedisManager.set_key already prepends config.redis_prefix.
  • Cloud-provider validation derived from the CloudProvider Literal instead of hardcoded sets.
  • DynamicRules.blocked_cloud_providers payloads filter through VALID_CLOUD_PROVIDERS with warning on ignored entries. Sync mirror patched.
  • @block_clouds decorator filters unknown cloud providers instead of silently storing them.
  • @block_countries / @allow_countries decorators uppercase-normalize ISO codes to match the geo handler's output. Sync mirror updated.
  • Country normalization in dynamic rules. _apply_country_rules in async + sync DynamicRuleManager uppercases inputs and stores frozenset[str].
  • Cloud-IP store class-as-factory resolution. HandlerInitializer now treats a bare class object passed via cloud_ip_store=RedisCloudIpStore as a factory and invokes it with redis_handler.
  • Lazy-init partial-failure isolation. _run_lazy_init wraps cloud-IP and geo-IP initialization in independent try blocks so a cloud failure no longer disables geo init. Important now that lazy_init=True is the default.
  • PR #19 fallout cleanup. Cleared 14 ruff F821/UP037 errors and 5 mypy errors left behind by PR #19.
  • SecurityConfig.dynamic_rule_interval is now actually honored. to_agent_config() previously dropped this field on the floor; the agent's _rules_loop ran on a hardcoded 300s regardless of what users configured. Fixed by forwarding the value through to AgentConfig.dynamic_rule_interval. Effective once guard-agent >= 2.6.0 is installed.

Changed

  • lazy_init defaults to True. Cloud-IP refresh now runs in a background task; initialize_redis_handlers returns immediately. Set lazy_init=False to preserve the old synchronous-init behavior.
  • blocked_countries and whitelist_countries are now frozenset[str]. Pydantic validator accepts list/tuple/set/frozenset and normalizes to uppercase.
  • SecurityConfig.block_cloud_providers field annotation now uses set[CloudProvider] | None (the Literal alias) instead of inline Literal["AWS", "GCP", "Azure"].

Added

  • cloud_ip_store accepts a CloudIpStoreFactory callable (Callable[[RedisHandlerProtocol], CloudIpStoreProtocol]). Sync mirror exposes SyncCloudIpStoreFactory.
  • CloudProvider Literal alias and VALID_CLOUD_PROVIDERS frozenset exported from guard_core.models.
  • rate_limit_script_reloaded SecurityEvent emitted on NOSCRIPT recovery.
  • SecurityConfig.agent_status_interval, new int field (default 300, range 60-86400) controlling how often the agent reports its status to the SaaS dashboard. Forwarded to AgentConfig.status_interval. Pairs with guard-agent >= 2.6.0 which actually honors the value (the agent previously hardcoded 300).

v3.0.0 (2026-04-29)

Fail-secure by default, broader cloud-provider coverage, agent encryption + version propagation (v3.0.0)

Breaking changes

  • SecurityConfig.fail_secure now defaults to True. Any unhandled exception inside a security check now blocks the request with HTTP 500 instead of logging the error and falling through. Bugs in checks that previously slipped past as silent fail-open responses now surface immediately. To restore the old behavior on deployments that depend on it, set fail_secure=False explicitly:
config = SecurityConfig(fail_secure=False)

Recommended migration: keep the new default and fix any check exceptions that surface, the previous default could mask serious bugs.

Added

  • fetch_digitalocean_ip_ranges(), pulls the DigitalOcean geofeed CSV from https://www.digitalocean.com/geo/google.csv and returns the set of CIDRs (IPv4 + IPv6).
  • fetch_linode_ip_ranges(), pulls the Linode/Akamai RFC8805 CSV from https://geoip.linode.com/.
  • fetch_vultr_ip_ranges(), pulls the Vultr/Constant geofeed JSON from https://geofeed.constant.com/?json.
  • All three providers wired into _ALL_PROVIDERS, the CloudManager singleton initializer, and the three provider→fetcher dispatch maps (_refresh_providers, refresh_async, _refresh_providers_via_redis_handler). Sync mirrors updated in lockstep using requests instead of aiohttp.
  • Each fetcher gracefully returns an empty set() on any HTTP / parse failure with logging.error(...). Malformed CIDR rows in CSV feeds are skipped silently rather than discarding the entire feed.
  • SecurityConfig.agent_project_encryption_key: str | None, per-project AES-256-GCM key the framework adapter passes through to the agent. When set, the agent posts to /api/v1/events/encrypted with the encrypted payload; when None, the agent uses the plaintext ingest path. Required for any API key whose SaaS-side configuration enforces payload encryption, without it the SaaS rejects every batch and the agent's ingestion breaker stays tripped. to_agent_config() propagates this directly to AgentConfig.project_encryption_key.
  • SecurityConfig.agent_guard_version: str | None, framework wrapper version (e.g. fastapi_guard.__version__) propagated to the agent so the SaaS can attribute telemetry to the wrapper version, not just the agent version. to_agent_config() propagates this to AgentConfig.guard_version. Pairs with guard-agent >= 2.4.0's EventBatch.guard_version field; older agents silently drop the kwarg via Pydantic's default extra='ignore'.

Notes

  • Alibaba was evaluated for inclusion but no reliable official public IP-range feed could be confirmed. Deferred to a follow-up rather than ship a guessed URL.

v2.2.2 (2026-04-29)

Safer failures, observability, and truthful copy (v2.2.2)

  • Fixed, Decode iteration cap raised from 3 to 7 in ContentPreprocessor.decode_common_encodings to cover up to 7-layer polyglot encoding evasion (base64(base64(base64(base64(payload)))) and similar). The loop still terminates on if content == original: break, so it stays bounded. Sync mirror updated in lockstep.
  • Fixed, IPInfoManager.get_country no longer raises RuntimeError("Database not initialized") when the MaxMind reader is unset; it now logs a WARNING and returns None. Callers no longer need to wrap every geo lookup in a defensive try/except. Sync mirror.
  • Fixed, ErrorResponseFactory.apply_modifier catches exceptions raised by the user-supplied custom_response_modifier, logs via logger.exception, and returns the unmodified response. A buggy modifier can no longer crash the request pipeline. Sync mirror.
  • Added, IPBanManager.banned_ips is now an _ObservableTTLCache that exposes evictions_count on the manager and emits a WARNING every 100 overflow evictions. Only overflow evictions are counted; TTL-expiry deletions are excluded (verified against cachetools source, expire() uses Cache.__delitem__, not popitem). Sync mirror.
  • Added, HandlerInitializer.initialize_dynamic_rule_manager emits a WARNING when enable_dynamic_rules=True but no agent handler is reachable, so the silent fall-back to static config is now visible to operators. The opt-out path (enable_dynamic_rules=False) remains silent. Sync mirror.
  • Changed, README and CHANGELOG copy aligned with what the engine actually does. Replaced "intelligent / behavioral analysis / anomaly detection / penetration detection" framing with signature-based detection plus multi-metric semantic scoring. Added a "How Detection Works" section to the README walking through the decode → regex match → semantic-score → ReDoS-guard pipeline.

v2.2.1 (2026-04-27)

RedisManager singleton hardening (v2.2.1)

  • Fixed, RedisManager.__new__ always created a new instance and overwrote the class-level _instance reference, breaking the singleton contract. When middleware or a fixture called RedisManager(config) more than once, each successive call orphaned the previous instance, but each instance owned an independent _redis connection set by its own initialize(). The orphaned connection had no closer; on garbage collection it surfaced as ResourceWarning: unclosed Connection (and the underlying socket / asyncio transport). Under pytest -W error this manifested as cascading PytestUnraisableExceptionWarning failures across any test suite that constructed RedisManager more than once.
  • __new__ now follows the same true-singleton pattern as RateLimitManager: create the instance once, update config on every call, return the same instance. Connections are owned by a single live instance and close() actually closes them.
  • Mirror fix applied to guard_core.sync.handlers.redis_handler.RedisManager.
  • No behavior change for production callers that construct RedisManager once at startup. Test suites that previously leaked redis connections across fixtures now run clean under -W error.

v2.2.0 (2026-04-26)

Phase 1 hardening, CORS, fail-secure, CIDR bans, preprocessor fixes, concurrency safety

Added

  • guard_core.handlers.cors_handler, framework-agnostic CORS preflight + response-header module consumed by every adapter. Provides CorsHandler, CorsPreflightResponse, and is_preflight.
  • SecurityConfig.fail_secure field (default False), when True, an unhandled exception in any check blocks the request instead of falling through.
  • IPBanManager.ban_ip accepts CIDR networks (10.0.0.0/24, 2001:db8::/32) for both IPv4 and IPv6. Invalid networks raise ValueError.
  • Preprocessor encoding decoders: base64 (length-bounded), \xNN hex, and \uNNNN JS unicode escapes are decoded inside the existing 3-iteration loop.
  • Preprocessor SQL comment stripping: case-aware in-keyword comment removal (SELE/**/CTSELECT, sele/**/ctselect) plus space-replacement for between-token cases (1/**/OR1 OR). Line comments (--, #) replaced with whitespace.

Fixed

  • <?php attack-indicator regex now matches the literal PHP open tag (was <?php which made < optional and matched any string containing php). #6
  • Truncated preprocessor output now interleaves attack regions and gaps in source order (was reversing gaps via insert(0, ...)). #7
  • fail_secure is now actually enforceable; the previous hasattr guard always returned False because the field was undeclared on SecurityConfig.
  • Compiled-regex cache key is deterministic ({pattern}:{flags}) instead of using process-salted Python hash(), eliminating cross-pattern collisions.
  • Sync RateLimitManager serializes in-memory state with threading.Lock, avoiding RuntimeError: deque mutated during iteration under multi-threaded WSGI servers.
  • IPBanManager.ban_ip refuses ban durations longer than the local cache TTL when Redis is unavailable; raises ValueError instead of silently truncating to one hour.
  • DynamicRuleHandler._apply_rules snapshots config before mutating and rolls back on exception. Concurrent rule pushes serialize under a lock (asyncio.Lock async, threading.Lock sync).

Internal

  • Test infrastructure: tests/test_decorators/test_behavior_handler.py and tests/test_sync/test_decorators/test_behavior_handler.py now correctly close their Redis connections in teardown (previously leaked, surfacing as ResourceWarning errors under -W error).

v2.1.0 (2026-04-25)

lazy_init: background warmup instead of first-request stall

Changed

  • lazy_init=True now schedules the IPInfo MMDB download and cloud-IP provider fetches as a background task during initialize_redis_handlers(), instead of triggering them synchronously on the first request that needs them. Eliminates the multi-second latency spike on the first user request. During the warmup window (typically a few seconds at startup), cloud-provider blocking and country-based geo checks are inert; rate limiting, IP banning, pattern detection, and all other security layers remain fully active. After the background task completes, the geo/cloud layers activate seamlessly.
  • HandlerInitializer exposes _lazy_init_task, the asyncio.Task (or threading.Thread in the sync mirror) that runs the deferred cloud and geo bootstrap when lazy_init=True. Failures inside the background task are caught and logged via logging.getLogger("guard_core.core.initialization") (guard_core.sync.core.initialization for the sync mirror) at WARNING level; they never propagate.
  • CloudIpRefreshCheck.check() no longer triggers a synchronous cloud_handler.refresh_async(...) when ranges are empty under lazy_init=True. The interval-based scheduled refresh path is now the only refresh path inside the request lifecycle.

Compat notes

  • lazy_init=False (the default) is unchanged, eager init at startup.
  • Users who opted into lazy_init=True in 2.0.0 see only an upside: the first-request latency that 2.0.0 imposed is replaced with a brief startup-time warmup window where cloud/geo layers are inert. No code changes required.
  • lazy_init=True users with strict cloud-provider blocking who can't tolerate any warmup window should stay on lazy_init=False (or continue using lazy_init=True with a Kubernetes/ALB warmup probe that hits a health endpoint before real traffic). As of v3.8.0, your adapter's status surface (fastapi-guard: GET /_guard/status or SecurityMiddleware.get_initialization_status()) is what that health endpoint should read, see Provider Status.

v2.0.0 (2026-04-25)

Operator-facing security controls and pluggable IP lifecycle (v2.0.0)

Highlights

  • Detection exclusion knobs, global and per-route opt-out for headers, query params, and JSON body fields, plus per-category disablement for the 16 known threat categories (XSS, SQLi, dir traversal, cmd injection, …). The detection engine itself is unchanged (regex set + bag-of-words token-overlap scorer); this release adds operator-facing controls on top of it.
  • DetectionResult replaces tuple[bool, str]. Both detect_penetration_attempt() and detect_penetration_patterns() now return a dataclass carrying is_threat, trigger_info, threat_categories, and threat_scores. Callers that unpacked the tuple must migrate.
  • Per-category ban thresholds and durations. New ThreatBanConfig(threshold, duration) model and SecurityConfig.threat_ban_config: dict[str, ThreatBanConfig]. The check increments per-category counts; the first category that crosses its own threshold short-circuits the flat-threshold fallback. Reasons are tagged "penetration_attempt:<category>" for category bans and "penetration_attempt" for flat fallback.
  • Global behavior rules. SecurityConfig.global_behavior_rules: list[BehaviorRuleConfig] lets users configure 404-noise correlation and other behavioural patterns without decorators. When correlate_with_detection=True and the IP has any positive entry in suspicious_request_counts, the rule's effective threshold is halved (floor 1).
  • Lazy IP lifecycle + pluggable cloud-IP store. SecurityConfig.lazy_init=True defers IPInfo MMDB download and cloud-IP fetches until the first request. SecurityConfig.cloud_ip_store accepts a CloudIpStoreProtocol; default is in-memory, automatically upgraded to Redis-backed when Redis is wired. Horizontally-scaled deployments can pre-populate the store and skip per-instance cold starts.
  • Strict protocol typing. redis_handler and agent_handler parameters in IPInfoManager and CloudManager are typed against RedisHandlerProtocol / AgentHandlerProtocol instead of Any.
  • Test posture. 3124 tests, 100% line + 100% branch coverage on every touched file, zero pytest warnings, vulture clean (10 pre-existing findings fixed at the root), pre-commit chain (ruff, mypy, vulture, bandit, radon, xenon, deptry) all green.

Added

  • DetectionResult dataclass at guard_core.detection_result (sync mirror under guard_core.sync.detection_result).
  • ALL_DETECTION_CATEGORIES (frozenset of 16 labels) and CATEGORY_CONTEXT_MAP in guard_core.handlers.suspatterns_handler.
  • SecurityConfig fields: excluded_detection_headers, excluded_detection_params, excluded_detection_body_fields, enabled_detection_categories (default = full ALL_DETECTION_CATEGORIES set; rejects unknown labels).
  • RouteConfig override fields for the four detection-exclusion knobs (default None = inherit from SecurityConfig).
  • ContentFilteringMixin.detection_exclusion(headers=, params=, body_fields=, categories=) decorator; None args leave the corresponding RouteConfig field unchanged.
  • ThreatBanConfig(threshold, duration) model + SecurityConfig.threat_ban_config. Validator rejects unknown categories.
  • BehaviorRule.ban_duration: int | None (consumed by _execute_ban_action, defaults to 3600 when unset). BehaviorRule.correlate_with_detection: bool = False.
  • BehaviorTracker.track_return_pattern(..., effective_threshold=) override.
  • BehaviorRuleConfig model + SecurityConfig.global_behavior_rules: list[BehaviorRuleConfig]. Module-level config_to_rule(cfg) -> BehaviorRule helper.
  • BehavioralContext.middleware: Any = None field. BehavioralProcessor.process_global_return_rules() uses the existing _behavior_tracker() precedence helper (context tracker first, decorator tracker fallback) and short-circuits cleanly when neither is reachable.
  • ErrorResponseFactory.process_response() accepts an optional process_global_behavioral_rules callback and runs it alongside the existing route-specific path. client_ip is extracted once and shared across both paths.
  • SecurityConfig.lazy_init: bool = False.
  • SecurityConfig.geo_ip_db_max_age: int = 86400 (validated 3600 ≤ x ≤ 604800).
  • SecurityConfig.cloud_ip_store: CloudIpStoreProtocol | None = None.
  • GeoIPHandler protocol gained async refresh() and sync close().
  • IPInfoManager(token, db_path, max_age=...) with a refresh() method. _max_age replaces the hardcoded 86400 in disk-freshness checks and Redis TTL writes.
  • CloudIpStoreProtocol (and SyncCloudIpStoreProtocol mirror) with get / set / clear methods.
  • InMemoryCloudIpStore and RedisCloudIpStore default implementations under guard_core.handlers.cloud_ip_stores.
  • CloudManager.set_store(). refresh_async() reads from the store first, falls back to API fetch + write-back. Legacy redis_handler-only path preserved when _store is None.
  • HandlerInitializer.initialize_redis_handlers() wires cloud_handler.set_store(config.cloud_ip_store) after Redis bootstrap when an explicit store is configured. Cloud + geo bootstrap now skipped when lazy_init=True; CloudIpRefreshCheck triggers a one-shot init on the first request that needs cloud data.

Changed

  • detect_penetration_attempt(request, config=None, route_config=None)DetectionResult instead of tuple[bool, str].
  • detect_penetration_patterns(...)DetectionResult instead of tuple[bool, str].
  • GuardMiddlewareProtocol.suspicious_request_counts: dict[str, dict[str, int]] (was dict[str, int]). IP → category → count. Existing total-count semantics preserved via sum(values()) everywhere they were read.
  • SusPatternsManager.compiled_patterns and _pattern_definitions entries are 3-tuples (regex, contexts, category) (were 2-tuples). Every regex threat dict returned by _check_regex_pattern() now carries category. Custom patterns are tagged "custom" and bypass enabled_categories filtering.
  • SusPatternsManager.detect() and _check_regex_patterns() accept an enabled_categories: set[str] | None = None filter.
  • _check_value_enhanced() / _check_request_component() now return tuple[bool, str, list[dict]] (added the raw threats list so the public detector can extract categories and scores).
  • Cloud-IP cache Redis namespace moved from cloud_ranges (comma-separated values) to guard:cloud_ip (JSON-encoded sorted list). See Compat notes below.

Fixed

  • setup_custom_logging now closes each handler before removing it, instead of relying on logger.handlers.clear(). Closes a ResourceWarning for _io.FileIO that surfaced under pytest -W error::ResourceWarning.
  • Vulture clean. Removed 10 pre-existing dead-code findings: scheme parameter on GuardRequest.url_replace_scheme is now whitelisted (Protocol method body is ...; renaming would break callers passing the kwarg by name); the four unreachable code after raise findings in tests/test_handlers_integration.py (and sync mirrors) replaced their @asynccontextmanager mocks with class-based async/sync context managers that don't need a structurally-required dead yield.
  • Pydantic mypy plugin is now wired (plugins = ["pydantic.mypy"] in [tool.mypy]). Removed 10 obsolete # type: ignore markers and 2 stale # TODO: Add type hints to the decorator comments above @field_validator / @model_validator decorators in guard_core/models.py. Also dropped the now-unneeded [[tool.mypy.overrides]] module = "pydantic.*" follow_imports = "skip" block that was masking the plugin.
  • unasync.py gained a multi-line from tests.conftest import (...) rewrite rule and a substitution rule for the new cloud_ip_store_protocol import path. The sync mirror now correctly renames CloudIpStoreProtocolSyncCloudIpStoreProtocol, matching the project's Sync*-prefix convention for sync protocols.

BREAKING

  1. detect_penetration_attempt() and detect_penetration_patterns() return DetectionResult. Tuple-unpacking callers must migrate:

    # Before
    detected, trigger = await detect_penetration_attempt(request)
    # After
    result = await detect_penetration_attempt(request)
    detected, trigger = result.is_threat, result.trigger_info
    # Or read result.threat_categories / result.threat_scores for richer info.
    
  2. GuardMiddlewareProtocol.suspicious_request_counts: dict[str, dict[str, int]]. Code that reads or writes this attribute must use the nested-dict shape:

    # Before
    self.suspicious_request_counts[ip] += 1
    # After (per-category increment)
    self.suspicious_request_counts.setdefault(ip, {})
    self.suspicious_request_counts[ip][category] = (
        self.suspicious_request_counts[ip].get(category, 0) + 1
    )
    # Reading the total
    total = sum(self.suspicious_request_counts.get(ip, {}).values())
    
  3. SusPatternsManager compiled-pattern tuples are 3-tuples. get_all_compiled_patterns() returns tuple[Pattern, frozenset[str], str] instead of tuple[Pattern, frozenset[str]]. Direct callers that iterate this collection must unpack three elements.

  4. _check_value_enhanced / _check_request_component return 3-tuples. External callers (none in the framework adapters; flagged here in case downstream code reaches in).

  5. Cloud-IP cache namespace migration: cloud_rangesguard:cloud_ip. Any ops tooling or dashboards reading those Redis keys directly must switch over. The new format is JSON-encoded sorted list of CIDRs per provider, written under namespace guard:cloud_ip. The legacy comma-separated path is still reachable for users who explicitly set _store = None on the CloudManager singleton, but the default and the RedisCloudIpStore wiring use the new namespace.

Compat notes

  • All four framework adapters (fastapi-guard, flaskapi-guard, djapi-guard, tornadoapi-guard) need a release pinning guard-core>=2.0.0 and a small migration: any adapter middleware that read suspicious_request_counts[ip] as an int must read sum(suspicious_request_counts[ip].values()) (the protocol now reflects the per-category shape). Adapters that called detect_penetration_attempt/detect_penetration_patterns and unpacked the 2-tuple must consume DetectionResult.is_threat / .trigger_info.
  • lazy_init=False is the default and preserves existing eager startup. Existing deployments do not need to opt in.
  • enabled_detection_categories defaults to the full ALL_DETECTION_CATEGORIES set, so detection coverage is unchanged unless the user explicitly narrows it.
  • threat_ban_config defaults to an empty dict and falls back to the existing auto_ban_threshold / auto_ban_duration flat behaviour, existing configurations behave identically until per-category entries are added.
  • Pydantic mypy plugin was a typing tooling change; it does not affect runtime behaviour or installed dependencies.

Tooling

  • make sync (powered by scripts/unasync.py) regenerates the entire guard_core/sync/** tree plus matching tests/test_sync/**. Hand-edits are limited to files in unasync.py:TEMPLATE_FILES (a few sync protocol files); everything else is regenerated and verified via python scripts/unasync.py --check in pre-commit.
  • tests/conftest.py redis_cleanup fixture now teardowns Redis state after yield in addition to before it. Removes a previously-hidden test-order dependency that surfaced when running tests across many invocations.

v1.2.1 (2026-04-24)

Integration fixes caught by end-to-end smoke test (v1.2.1)

Fixed

  • OtelHandler.start() now normalizes the configured otel_exporter_endpoint by appending /v1/traces and /v1/metrics when the base URL lacks the signal path. Previously, users who set otel_exporter_endpoint="http://collector:4318" received 404 Not Found from every OTLP receiver. Matches the semantics of the OTEL_EXPORTER_OTLP_ENDPOINT environment variable. Also correctly rewrites explicit signal suffixes (/v1/traces, /v1/metrics, /v1/logs) so the traces exporter always gets /v1/traces and the metrics exporter always gets /v1/metrics regardless of which signal-specific path the user configured.
  • HandlerInitializer.build_enricher() now owns a BehaviorTracker instance when the user's SecurityDecorator does not supply one, and caches it as HandlerInitializer.behavior_tracker for reuse. Without this fix, guard.behavior.correlation_key and guard.behavior.recent_event_count never populated for adapters that instantiate the middleware and decorator separately (all four current adapters).
  • BehavioralContext gained an optional behavior_tracker field and BehavioralProcessor now threads writes through context.behavior_tracker when present, falling back to guard_decorator.behavior_tracker otherwise. This closes the architectural gap where the enricher read from one tracker while writes went to another, guard.behavior.recent_event_count now populates end-to-end when adapters thread the HandlerInitializer.behavior_tracker through their BehavioralContext construction (shipping in the next adapter releases).

Compat notes

  • No public API changes. OtelHandler._otlp_signal_endpoint is an internal helper. BehavioralContext.behavior_tracker has a default of None so existing callers continue to work unchanged.
  • Adapters should bump their guard-core>=1.2.1 pin to pick up all three fixes. See the matching fastapi-guard 5.1.1, flaskapi-guard, djapi-guard, tornadoapi-guard releases, those ship the adapter-side threading changes that complete the behaviour-correlation wiring.

v1.2.0 (2026-04-24)

Enriched telemetry: client-side EventEnricher gated on guard-agent (v1.2.0)

Highlights

  • Two-tier telemetry model. Raw OTel/Logfire signal stays free and unchanged. A new enriched tier, gated on enable_agent=True + enable_enrichment=True, adds project identity, deterministic threat scores, dynamic-rule correlation, and per-IP behavioural correlation to every event and metric the composite fans out. Every exporter (guard-agent, OTel, Logfire) sees the same enriched payload.
  • EventEnricher. New guard_core.core.events.enricher.EventEnricher + EnrichmentContext run inside CompositeAgentHandler.send_event / .send_metric between the mute filter and fan-out. Four independent strategies, each fails soft, a faulty strategy never blocks emission. Async + sync mirror parity maintained via scripts/unasync.py.
  • Eight guard.* enrichment keys. guard.project_id, guard.service.name, guard.deployment.environment, guard.threat_score, guard.rule.id, guard.rule.version, guard.behavior.correlation_key, guard.behavior.recent_event_count. All nullable, all absent unless the corresponding context exists.
  • Deterministic threat score. ThreatScorer.score_for(event_type) maps 16 event types to 0-100 scores defined in guard-core's _THREAT_SCORE_MAP (penetration_attempt=90, ip_banned=70, medium events=50, rate_limited=20, default=20). No ML, no server-side recomputation.
  • Dynamic-rule correlation. DynamicRuleManager.match_event(event) checks the cached rule against the event's IP / country / event-type and returns (rule_id, version) | None. The enricher attaches both keys when matched.
  • Behavioural correlation key. 16-char SHA-256 prefix of ip | service | floor(now/300), stable within a 5-minute rolling window. Combined with a new BehaviorTracker.get_recent_event_count(ip, window) that aggregates in-memory usage counters, dashboards can group correlated attack chains by IP.
  • OTel + Logfire forward guard.* metadata as span attributes. OtelHandler.send_event and LogfireHandler.send_event now walk event.metadata and attach every guard.* key (except traceparent / tracestate, which are still used for parent-context extraction only).
  • 100% line + branch coverage. 2751 tests passing, zero skips, zero # pragma: no cover.

Added

  • guard_core.core.events.enricher.EventEnricher + EnrichmentContext dataclass (sync mirror under guard_core/sync/).
  • guard_core.core.events.enricher.ThreatScorer.score_for(event_type) + deterministic _THREAT_SCORE_MAP.
  • Eight ENRICHMENT_KEY_* constants in guard_core.core.events.event_types (async + sync).
  • SecurityConfig.enable_enrichment: bool field with a validate_agent_config model validator that raises ValidationError when enrichment is requested without enable_agent=True.
  • HandlerInitializer.build_enricher() factory. build_composite_handler() now passes the enricher into CompositeAgentHandler; shutdown_agent_integrations() clears the enricher reference. The early-exit guard in initialize_agent_integrations now accounts for enable_enrichment.
  • CompositeAgentHandler(..., enricher=...) parameter; send_event / send_metric invoke the enricher between the mute filter and handler fan-out.
  • DynamicRuleManager.match_event(event) -> tuple[str, int] | None returning (rule_id, version) when the cached rule matches.
  • BehaviorTracker.get_recent_event_count(ip, window_seconds) -> int aggregating usage counts across all endpoints for the given IP.
  • OtelHandler.send_event + LogfireHandler.send_event forward guard.* metadata keys as span attributes.

Docs

  • docs/architecture/telemetry.md updated with: the two-tier model table, the new enable_enrichment config field, an enrichment-fields reference table, a dedicated "Enabling enrichment" section, documentation of dynamic-rule correlation matching, and documentation of the behavioural correlation key algorithm.

Compat notes

  • All new fields / layers are strictly additive. Existing configurations with enable_otel=True and/or enable_logfire=True continue to emit raw signal unchanged.
  • Adapters built against 1.1.0 continue to work against 1.2.0 without code changes, the enricher only activates when enable_enrichment=True, and that flag is False by default.

v1.1.0 (2026-04-24)

Telemetry v1: OpenTelemetry, Logfire, and composable muting (v1.1.0)

Highlights

  • OpenTelemetry export, opt-in via enable_otel=True. Emits guard events as spans and request metrics as OTLP-compatible instruments (guard.request.duration, guard.request.count, guard.error.count). Includes otel_service_name, otel_exporter_endpoint, and otel_resource_attributes for deployment/env/version tagging. Requires the guard-core[otel] extra.
  • Logfire export, opt-in via enable_logfire=True. Events as logfire.span("guard.event.<type>", ...), metrics as structured logfire.info calls. Requires the guard-core[logfire] extra.
  • W3C trace-context propagation, incoming traceparent and tracestate headers are forwarded so guard spans become children of the caller's trace across the whole request lifecycle.
  • Composable muting at three layers, muted_event_types, muted_metric_types, and muted_check_logs on SecurityConfig. Applied inside CompositeAgentHandler so every exporter (guard-agent, OTel, Logfire) sees the same mute rules. muted_check_logs also suppresses in-check log_activity() output, not just the pipeline logs.
  • CompositeAgentHandler + EventFilter, every telemetry exporter runs through one handler chain with a shared filter, so new exporters get muting / propagation for free.
  • Factory methods for adapters, HandlerInitializer.build_event_bus() and .build_metrics_collector() so framework adapters route through the composite instead of constructing SecurityEventBus / MetricsCollector directly. See Adapter upgrade notes below.
  • Validated mute values, muted_event_types, muted_metric_types, and muted_check_logs all validate at config time against EVENT_TYPE_VALUES / METRIC_TYPE_VALUES / CHECK_NAME_VALUES. Typos raise ValidationError with the full set of valid values in the message.
  • Idempotent handler lifecycle, OtelHandler / LogfireHandler start() / stop() are safe to call repeatedly; stop() nulls provider references so subsequent calls don't double-shutdown.
  • 100% line + branch coverage on every module touched (2597 tests, zero skips, zero # pragma: no cover).

Added

  • SecurityConfig.muted_event_types, muted_metric_types, muted_check_logs (validated set[str] fields).
  • SecurityConfig.enable_otel, otel_service_name, otel_exporter_endpoint, otel_resource_attributes.
  • SecurityConfig.enable_logfire, logfire_service_name.
  • guard_core.core.events.otel_handler.OtelHandler (async + sync mirror).
  • guard_core.core.events.logfire_handler.LogfireHandler (async + sync mirror).
  • guard_core.core.events.composite_handler.CompositeAgentHandler, composes guard-agent + OTel + Logfire behind one AgentHandlerProtocol, applies EventFilter at fan-out.
  • guard_core.core.events.event_types.EventFilter + EVENT_TYPE_VALUES / METRIC_TYPE_VALUES / CHECK_NAME_VALUES frozensets (30 / 3 / 17 members).
  • HandlerInitializer.build_event_bus(), .build_metrics_collector(), .build_composite_handler(), .shutdown_agent_integrations(), factory + lifecycle API for adapters.
  • SecurityCheck.log_if_allowed(), check-aware log_activity wrapper that honours muted_check_logs.
  • docs/architecture/telemetry.md, full field reference, troubleshooting, and adapter wiring guidance.
  • [otel] and [logfire] optional extras in pyproject.toml.

Fixed

  • logfire.metric(...) never existed, replaced with logfire.info("guard.metric.<type>", ...) for structured metric logs.
  • send_metric now warns (once per unknown type) instead of silently dropping when handed a metric_type outside METRIC_TYPE_VALUES.
  • OtelHandler.stop() is now idempotent (nulls _tracer / _meter) so shutdown hooks can call it safely on re-entry.
  • Sync mirror under guard_core/sync/ fully covers every async change (behavior, decorators, detection engine, handlers, checks, events, initialization, responses, routing, validation, bypass, behavioral).

Adapter upgrade notes

Framework adapters (fastapi-guard, flaskapi-guard, djapi-guard, tornadoapi-guard) must switch from constructing SecurityEventBus(agent_handler, ...) / MetricsCollector(agent_handler, ...) directly to calling initializer.build_event_bus() / initializer.build_metrics_collector() after initializer.initialize_agent_integrations(). Direct construction routes events to the bare agent handler and bypasses the composite entirely, meaning OTel, Logfire, and the event filter never see pipeline-level events or request metrics. Each adapter will publish a matching minor version pinning guard-core>=1.1.0,<2.0.0 with this wiring fix.

Docs

  • New docs/architecture/telemetry.md covering the two-tier model (raw OTel/Logfire signal; guard-agent as a parallel enriched exporter), mute field reference with all valid values, incoming traceparent/tracestate behaviour, and troubleshooting for missing spans / inactive mutes / logfire.configure() warnings.
  • Install and extras documentation moved to uv-first tabs (uv add "guard-core[otel]", then poetry, then pip) across docs/index.md, docs/llms.txt, docs/architecture/telemetry.md.

v1.0.3 (2026-04-05)

Added

  • Guard processing time instrumentation on all request-scoped SecurityEvent objects via get_pipeline_response_time(). Covers events from SecurityEventBus, SecurityCheckPipeline, RateLimitManager, BaseSecurityDecorator, and send_agent_event(). Timing starts at pipeline entry and lazily initializes for events fired before or after the pipeline (bypass, behavioral). No adapter-level changes required

v1.0.2 (2026-04-05)

Fixed

  • Removed _check_ip_spoofing() which incorrectly flagged every request with X-Forwarded-For headers as a spoofing attempt when trusted_proxies was not configured (the default)
  • Added IP caching in extract_client_ip to avoid redundant lookups across the request lifecycle

Added

  • Guard processing time instrumentation on all request-scoped SecurityEvent objects via get_pipeline_response_time(). Covers events from SecurityEventBus, SecurityCheckPipeline, RateLimitManager, BaseSecurityDecorator, and send_agent_event(). Timing starts at pipeline entry and lazily initializes for events fired before or after the pipeline (bypass, behavioral). No adapter-level changes required

v1.0.1 (2026-03-28)

Fixed

  • Removed false-positive suspicious patterns that blocked legitimate web traffic:
  • Static file extensions (.html, .js, .css, .png, .jpg, .svg, .webp, .bmp, .pl, .properties)
  • Common API prefixes (/api/, /rest/, /v1/, /v2/, /status/, /config/)
  • Authentication paths (/login, /signin, /account/login)
  • Admin paths (/admin)
  • Static asset directories (/images/, /css/, /img/, /scripts/)
  • Retained detection for actual recon indicators: legacy server extensions (.asp, .aspx, .jsp, .cfm, .cgi, etc.), and suspicious management endpoints (/management, /config_dump, /credentials)

v1.0.0 (2026-03-25)

Added

  • Complete synchronous API (guard_core.sync) generated via scripts/unasync.py, including sync versions of all 17 security checks, handlers, decorators, protocols, detection engine, and utilities
  • scripts/unasync.py transformation tool converting async code to sync (async def to def, await removed, aiohttp to requests, redis.asyncio to redis, asyncio.Lock to threading.Lock)
  • Sync protocols: SyncGuardRequest, SyncGuardMiddlewareProtocol, and sync versions of all handler protocols
  • PEP 561 type stub markers (guard_core/py.typed, guard_core/sync/py.typed)
  • Project governance files: CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md
  • README.md with project documentation, badges, and ecosystem overview
  • .safety-project.ini for dependency vulnerability scanning
  • MANIFEST.in and .gitattributes for packaging
  • .python-version specifying supported Python versions (3.10-3.14)
  • Comprehensive edge-case test suites for cloud provider, HTTPS enforcement, IP security, rate limiting, and time window checks
  • docs/llms.txt for LLM-assisted development context
  • Complete sync test suite (tests/test_sync/) mirroring the async test structure

Changed

  • Restructured and consolidated the entire test suite into organized directories (test_agent/, test_core/, test_decorators/, test_features/, test_handlers/, etc.)
  • Enhanced CloudManager with IP range change logging and improved provider refresh logic
  • Updated SusPatternsManager with additional detection logic
  • Enhanced BehavioralProcessor, ErrorResponseFactory, and RouteConfigResolver internals
  • Minor updates to IPInfoManager handler
  • Updated BaseSecurityDecorator route config handling
  • Added mypy override for guard_core.sync.* (type suppression for generated sync code)
  • Documentation fully standardized and verified for accuracy against source code
  • Disabled safety pre-commit hook temporarily

Fixed

  • Suspicious pattern handling in detect_penetration_attempt

v0.1.0 (2026-03-23)

New Features (v0.1.0)

  • Initial release: Guard Core extracted as a framework-agnostic security library for Python web applications.
  • Protocol-based architecture: Uses GuardRequest and GuardResponse protocols for framework independence.
  • Full feature parity: All security features available through framework-agnostic APIs.
  • IP Management: Whitelisting, blacklisting, geolocation, cloud provider blocking.
  • Rate Limiting: Sliding window algorithm with in-memory and Redis backends.
  • Penetration Detection: Enhanced detection engine with pattern matching, semantic analysis, and performance monitoring.
  • Security Decorators: Route-level security controls for access control, authentication, rate limiting, behavioral analysis, content filtering, and advanced features.
  • Security Headers: Comprehensive HTTP security header management following OWASP best practices.
  • Redis Integration: Distributed state management for multi-instance deployments.
  • Behavioral Analysis: Usage monitoring, return pattern detection, and frequency analysis.