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 existingrequire_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_headersno longer fully silences a header. User-added headers inherit identity-header logic (skippingssrfonly for IP values) and are scanned by all other categories. - Grammar-based sensitive data redaction. Unified grammar-based redaction across log lines,
pattern_detectedthreat events, exception logs, query strings, body fields, and hook payloads using configurable sensitive name sets. - Dynamic rules persistence fix. Resolved a
LastKnownDynamicRulesschema validation error indump_last_known_rules_snapshotthat 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](defaultfrozenset()). 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](defaultfrozenset()). 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](defaultfrozenset()). Names JSON keys, form fields, and multipart text parts replaced with[REDACTED]in field detection lines. Extends_DEFAULT_SENSITIVE_LOG_FIELDS._DEFAULT_SENSITIVE_LOG_HEADERSand_DEFAULT_SENSITIVE_LOG_FIELDSexported fromguard_core.utils.log_activityparameters. Gainedsensitive_headersandsensitive_paramsparameters 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_snapshotvalidation exception. Stripped unknownDynamicRulesfields (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.SecurityConfigcollection field revalidation. Revalidated 13 collection fields (exclude_paths,cors_allow_origins,custom_error_responses, etc.) on attribute reassignment andmodel_copy(update=...).- SQLi and category detection context gaps. Expanded SQLi, command injection, and traversal detection to
headerandurl_pathcontexts. Kept noise-prone patterns (ORDER BY nbare) restricted to bodies/params. - Embedded JSON detection on form/multipart fields. Form and multipart fields now trigger embedded-JSON walks without bypassing redaction.
- Uncalled
SecurityDecoratorevent methods. Connected six orphaned event methods (send_access_denied_event,send_rate_limit_event, etc.) to pipeline checks. - Threat event pattern metadata.
pattern_detectedevents now populatepattern_matched(redacted regex source),rule_type,decorator_type, andmetadata.threat_categories. - Country whitelist loopback handling. Loopback addresses (
127.0.0.1,::1) and explicitly whitelisted IPs are now exempt fromwhitelist_countriescheck 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-fileare now redacted, andline-numberis coerced to an integer. - Multipart CRLF filename bypass. Headers on multipart parts beyond
Content-Dispositionare 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_loggingnow preserves existing handlers attached to theguard_corelogger by external applications.
Changed¶
SecurityConfigerror formatting. Enabledhide_input_in_errorsonSecurityConfigto 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.pyand 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_fieldsand_try_check_json_valuein 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 (unknownschema_versionor 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, withenable_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_pathconfig 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 orPath); 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_expiredno 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_blockcallback fires exactly once per blocked request.on_blockreceives(request, payload)at the block decision inSecurityCheckPipeline, with a payload carryingcheck_name,reason,trigger_info,passive_mode,client_ip,path,methodandstatus_code, so an application can observe what guard-core decided in-process instead of reconstructing it from logs. It is deliberately not fired forcustom_request_checkor routecustom_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 withon_error). In passive mode it fires at flag time withstatus_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 raisesTypeErrorwith 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 insidelog_activity, share one payload builder inguard_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_headersraisedKeyErrorunder 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, escapingget_headersinto the pipeline's generic handler (a 500 withfail_secure=True, a silently skipped check withfail_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_bannedandunban_ipdecided against thebanned_ipsTTLCache 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 withfail_secure=Trueand, with the defaultfail_secure=False, a request whose ban check silently did not run. Both now decide with a singlebanned_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¶
@bypassdecorator filters unknown check names. Previously@bypass(["rate_limit", "geo_check"])silently stored"geo_check"on the route config'sbypassed_checks, where it had no effect:should_bypass_checkonly 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 newVALID_BYPASS_CHECKSfrozenset, the same way@block_cloudsfilters throughVALID_CLOUD_PROVIDERS(added in v3.1.0), and warns on ignored entries. Async and sync mirrors updated. This also means thesecurity_bypassmiddleware event'sbypassed_checkspayload now reports only recognized tokens, so a caller who previously passed extra labels for bookkeeping throughbypass()will no longer see them there.
Added¶
VALID_BYPASS_CHECKSfrozenset exported fromguard_core.models, alongsideVALID_CLOUD_PROVIDERS. Holds the six tokensshould_bypass_checkrecognizes:"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_openon a Redis failure instead of always falling back to the in-memory window.redis_fail_open=Truestill falls back to the in-memory window but now logs aWARNINGonce per process instead of anERRORon every request;redis_fail_open=False(the default) now raisesGuardRedisError, letting the pipeline applyfail_secureexactly 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(default32) 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 newSecurityConfig.detection_max_scan_chars(default65536) 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_depththat 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-Lengthexceedsdetection_max_body_inspect_bytesis no longer silently skipped when the adapter supports a bounded read; the firstdetection_max_body_inspect_bytesbytes 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 fromconfigitself whenever it is unconfigured or was last configured from a different config object, so a direct caller (guard-core-mcp'scheck_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.pyand_redos_structure.pyare 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 syncBehaviorTrackerstores are now thread-locked, closing aRuntimeErrorunder a real WSGI thread pool; anX-Forwarded-Forentry carrying a port (1.2.3.4:5678,[2001:db8::1]:5678) is now parsed instead of discarding the whole header.
Added¶
SecurityConfignow warns at construction whenwhitelistcontains a/0network. Awhitelistentry of0.0.0.0/0or::/0makes every address whitelisted, soblacklist,blocked_countriesand IP bans can never block anyone; this was previously silent. Precedence and every access decision are unchanged, a/0whitelist still allows everyone, this is a signal only (#79).
Changed¶
PerformanceMonitor.record_metricno longer recomputes its running average withstatistics.mean's exact-Fractionarithmetic. Profiling thedetection_max_scan_charsbudget'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_timeis nowmath.fsum(stats.recent_times) / len(stats.recent_times), the same approachmonitor_anomalies.pyalready 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.analyzeno longer tokenizes its input twice.analyze_attack_probabilityand thetoken_countfield both calledextract_tokensindependently on the same content;analyzenow 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_patternscalledpreprocess_url_decoded_newline_preserving, which independently repeateddecode_common_encodingson 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_openon a Redis failure instead of always falling back to the in-memory window._redis_request_count(check_rate_limitandcheck_rate_limit_by_ip,guard_core/handlers/ratelimit_handler.py) previously caught every Redis error itself and silently used the in-memory window regardless ofredis_fail_open, so a Redis outage silently turned a shared, cross-worker rate limit into a per-process one (N workers effectively multiplyingrate_limitby N) with no way to opt out.redis_fail_open=Truestill falls back to the in-memory window, but now logs aWARNINGonce per process ("Redis unavailable for rate limiting...") instead of anERRORon every request.redis_fail_open=False(the default) now raisesGuardRedisErrorinstead of silently falling back:check_rate_limitletsSecurityCheckPipeline._handle_check_errorapplyfail_secureexactly as for any other check's Redis failure (500 underfail_secure=True, pass-through otherwise), andcheck_rate_limit_by_iplets the exception reach its own caller. A deployment relying on the previous always-fall-back behavior with the defaultredis_fail_open=Falsenow needs to setredis_fail_open=Trueexplicitly to keep it.NoScriptErrorreload 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.pyandguard_core/detection_engine/_redos_structure.pywere 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_bytescap boundary was missed when the client declared an oversizedContent-Length, but caught when it did not._read_capped_body_prefix(no declaredContent-Length, or one at or under the cap) fetchedmax_bytesplus 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 declaredContent-Lengthover the cap, added in the previous fix wave) read exactlymax_byteswith 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_asyncand_refresh_providers_via_redis_handlerno 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-memoryip_rangeshad already been updated, sois_cloud_ipreported 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_patternsouter 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_createpattern already used for the inner store, dropping that endpoint's entire inner store on eviction.- The sync
BehaviorTracker.usage_counts/return_patternsin-memory stores had no lock, unlike their async counterparts (which never need one).get_recent_event_countiteratedusage_counts.values()whiletrack_endpoint_usage/track_return_patterninserted or LRU-evicted entries from another thread with no synchronisation; under a real WSGI thread pool this raisedRuntimeError: 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 athreading.Lockin 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 plainthreading.Lockneeds 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_warnedflag now flips under its existing_by_ip_locktoo, so the warn-once guarantee holds under threads. - An
X-Forwarded-Forentry carrying a port was discarded, falling back to the connecting peer for the whole header.1.2.3.4:5678and[2001:db8::1]:5678failedip_address()parsing outright, since neither_strip_ip_bracketsnor 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]:portshape 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 forhost:portand is left unchanged, as is an entry that looks likehost:portbut 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_loggingunconditionally attached a consoleStreamHandlerto theguard_corelogger and leftpropagate=True; a host that had already configured its own root logger handlers (the commonlogging.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_loggingnow always attaches its console handler, but that handler carries alogging.Filterthat checkslogging.getLogger().handlersat 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. Thecustom_log_filehandler 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 setcustom_log_file, or attach its own handler directly to theguard_corelogger.
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 raisedRecursionErrorout ofdetect_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 newSecurityConfig.detection_max_json_depth(default32,1to1000) 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 bydetection_max_content_length, and a one-time warning names the client IP._check_value_enhancedno longer treatsRecursionErroras 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 caughtjson.JSONDecodeErroraroundjson.loads; a value holding around 1000 nested braces raisedRecursionErrorinstead, out ofdetect_penetration_attempt, before the surrounding fallback logic ever ran.RecursionErroris 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 existingdetection_max_json_depthwarning fires once per request. - A declared
trusted_proxy_depththat over-counts the real proxy hops let a client rotate its resolved identity freely (GHSA-8xvm-856x-7hwp, claim 1 residual). Withtrusted_proxiesnon-empty,extract_client_ipselectedips[-trusted_proxy_depth]fromX-Forwarded-Forwith 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 namingtrusted_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 whentrusted_proxiesis 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-Lengthexceedsdetection_max_body_inspect_bytesis no longer silently skipped when the adapter supports a bounded read (GHSA-3hfx-8m47-5f9h residual)._read_capped_bodyreturnedNonefor the whole request body once the declared length exceeded the cap, so an attacker could evade body-based detection entirely just by declaring an oversizedContent-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 firstdetection_max_body_inspect_bytesbytes 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 newSecurityConfig.detection_max_scan_chars(default65536,1024to262144) separately bounds the total characters handed to the pattern engine per request, at the same accounting pointdetection_max_scan_valuesuses; 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 fromconfigitself 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 callsus_patterns_handler.configure(config)first: a direct caller that never did (guard-core-mcp'scheck_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_patternsare now coroutines, anddetection_max_scan_valueshas a floor. All three reporting methods areasync defin the async tree and must now be awaited; theguard_core.syncmirror keeps them synchronous.SecurityConfig.detection_max_scan_valuesnow requires at least2(each named value costs two scan units, so1could never scan a value) (GHSA-3hfx-8m47-5f9h). - Missing-client requests rejected; new
unixtrusted-proxy token. A request with no client address (request.client_hostisNone) is now rejected instead of skipping the entire security pipeline; a new"unix"token intrusted_proxiesresolvesX-Forwarded-Forbehind 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.
SecurityConfignow warns at construction on a/0trusted-proxy network (0.0.0.0/0,::/0) and on an emptyenabled_detection_categorieswith detection enabled, two previously silent misconfigurations (#79). - Ban canonicalisation.
ban_ip,is_ip_banned, andunban_ipcanonicalise 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=Truedegrades to in-memory backends,redis_fail_open=Falsere-raises so the adapter returns a clean error (#76). - GeoIP last-known-good.
IPInfoManagerkeeps 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
AzureCloudservice tag by name instead of whichever tag sorts first (#77). - Rate-limit stores bounded;
Retry-Afteron 429s. In-memory rate-limit stores are now LRU-bounded at 10,000 IPs; every 429 response now carriesRetry-After(GHSA-g53w-gmp9-9ch3, #81). - Behavior-tracker stores bounded.
BehaviorTracker.usage_counts/return_patternsper-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 withmath.fsum-based float mean/variance, removing an unbounded per-value CPU tax (GHSA-grqq-qh92-hw79). - Per-request scan cap.
SecurityConfig.detection_max_scan_valuesbounds 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
Authorizationheader, andRedisManager.initializestrips userinfo fromredis_urlbefore 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.
ssrfnow detects the IPv4-mapped IPv6 loopback bracket form and a trailing-dotlocalhost(#81).
Added¶
- New
SecurityConfig.detection_max_scan_valuesbounds the number of request values scanned per request, including JSON embedded within a single query-parameter, header, or body value. Default512. Once reached, remaining query-parameter, header, and body values are not scanned, and a one-timelogger.warningnames 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, andget_problematic_patternsare now coroutines in the async tree. They snapshotrecent_metrics/pattern_statsunder the monitor's lock before reading, the same fix applied torecord_metric's statistical-anomaly check; a direct caller must nowawaitthem.SusPatternsManager.get_performance_stats, the only production caller, was alreadyasyncand its own call signature is unchanged. Theguard_core.syncmirror keeps all three as plain methods.
Fixed¶
- A request with no client address (
request.client_hostisNone, 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=Falseruns 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"intrusted_proxiesdoes not start failing orchestrator probes. A new"unix"token intrusted_proxiesmarks a peer-less connection as a trusted hop, soX-Forwarded-Forstill resolves the real client on Unix-socket deployments. Underfail_secure=False,check_ip_accessnow treats the"unknown"identity as no address available: the request is allowed unless a whitelist or a country allow-list is configured (whitelistorwhitelist_countries), and the blacklist,blocked_countries, and cloud-provider checks are skipped since none can match without an address;check_route_ip_accessapplies the same rule to a decorated route'sip_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_depthsemantics are unchanged: it is not reinterpreted as a ceiling (GHSA-8xvm-856x-7hwp). SecurityConfignow warns at construction on two silent-gap configurations. Atrusted_proxiesentry that is a/0network (0.0.0.0/0,::/0) trusts every peer to setX-Forwarded-For; an emptyenabled_detection_categorieswithenable_penetration_detection=Trueruns detection that can never match anything. Both previously succeeded with no signal; both now log aWARNINGand still succeed (#79).ban_ip,is_ip_bannedandunban_ipnow canonicalise the address before storing, querying or deleting it.2001:DB8::1,2001:db8::1and the fully expanded form (and IPv4-mapped IPv6 like::ffff:1.2.3.4vs1.2.3.4) were previously three different keys on both the in-memory cache and Redis, sounban_ipcalled with a different spelling than the one used to ban was a silent no-op. CIDR entries are unaffected (_canonicalize_ipreturns non-address strings unchanged) (#81).HandlerInitializer.initialize_redis_handlersno longer lets a Redis outage at startup crash the app.GuardRedisErrorfromRedisManager.initialize()is now caught and logged;redis_fail_open=Truedegrades to the in-memory backends used when Redis is disabled instead of wiringip_ban_manager, the rate limiter, andsus_patterns_handlerto Redis, andredis_fail_open=False(default) re-raises so the adapter returns a clean error instead of an unhandled exception.sus_patterns_handler.initialize_redisandIPInfoManager.initialize/_download_databasenow 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).IPInfoManagerkeeps the last known good GeoIP database on a failed refresh or download.initialize()andrefresh()no longer delete the on-disk.mmdbfile or clearreaderwhen a download fails;maxminddb.open_databaseis now guarded at every call site, and only an unreadable (corrupt) file is removed.refresh()no longer clearsreaderbefore attempting the download, and only swaps in the new reader once the fresh database opens successfully (#78).- Azure cloud-IP fetch now loads the
AzureCloudservice tag instead of whichever tag sorts first. The Microsoft ServiceTags document lists many tags per download;fetch_azure_ip_rangespreviously tookvalues[0], which is not guaranteed to beAzureCloud. It now selects the entry namedAzureCloudand 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, andRateLimitManager.request_timestampsgrew 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_countsalready uses._handle_rate_limit_exceedednow setsRetry-Afterto 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_patternsper-client in-memory stores are now bounded. Each outer key (endpoint id, orendpoint:patternfor 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 andget_recent_event_countare unchanged (GHSA-g53w-gmp9-9ch3).PerformanceMonitor's statistical-anomaly check no longer uses exact-Fraction arithmetic.detect_statistical_anomalycalledstatistics.mean/statistics.stdevon everyrecord_metriconce a pattern's window warmed tomin_samples_for_anomaly, an unbounded per-value CPU tax on all enhanced-mode traffic (CWE-400); replaced withmath.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_databasesends the IPInfo token as anAuthorization: Bearerheader instead of a?token=query-string parameter, and_send_geo_event'sreasonno longer embeds a raw exception'sstr()(which could carry the request URL viaaiohttp.ClientResponseError/requests.HTTPError); reduced to the exception class name plus HTTP status when available.RedisManager.initializestrips userinfo fromredis_urlbefore it reaches theredis_connection/redis_erroragent events, keeping onlyscheme://host[:port]. Async and sync mirrors updated identically (issue #80). - Custom security-header names are now validated, including Redis-loaded ones.
SecurityHeadersConfigMixin._validate_header_nameenforces 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_confignow runs every cachedcustom_headersname 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). ssrfdetects the IPv4-mapped IPv6 loopback bracket form and a trailing-dotlocalhost.http://[::ffff:127.0.0.1]/andhttp://localhost./now match thessrfcategory.http://[::ffff:8.8.8.8]/(a public address in the same bracket shape) and the existinglocalhost.example.com/notlocalhost.iolookalikes stay unflagged. Sync mirror updated in lockstep (issue #81).guard_core.handlers.behavior_handlerandguard_core.sync.handlers.behavior_handlercan now be imported standalone. The behavior-tracker bound-store fix above pulled_lru_pop_or_createin fromguard_core.core.checks.helpers, which importsguard_core.core, whose package init reaches back throughguard_core.decorators.behavioraltoBehaviorRulein the still-initializingbehavior_handlermodule; a fresh interpreter raisedImportError: cannot import name 'BehaviorRule' from partially initialized module 'guard_core.handlers.behavior_handler' (most likely due to a circular import), breakingimport guardin fastapi-guard._lru_pop_or_createnow lives in a dependency-free leaf module,guard_core._utils.lru_store;helpers.pyre-exports it andbehavior_handler.py/ratelimit_handler.pyimport it directly, so neither pulls inguard_core.coreat module load. Sync mirror regenerated; the hand-maintainedguard_core/sync/handlers/ratelimit_handler.pyupdated to match.RedisManager.initializeno 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 overwroteself._rediswithout closing the client already there, leaking a redis-py connection pool that GC later finalised withResourceWarning: unclosed Connection; theenable_redis=Falsebranch and the connection-failure branch dropped their client the same way.initializeandclosenow route through a shared_discard_clientthat 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_metricno longer runs its statistical-anomaly check outside the lock._check_anomaliesiterated a pattern'srecent_timesdeque (math.fsumplus a generator) after the lock guarding appends to that same deque had already been released; a concurrentrecord_metriccall under real OS threads (the sync tree, e.g. gunicorn--threads) could append mid-iteration and raiseRuntimeError: 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_checkcalledpattern.search()directly on every compiled pattern with no scan-window bound and no timeout, so any exception in the enhanced path (including thePerformanceMonitorrace above) silently re-exposed the quadratic built-in patterns GHSA-r7hm-rjvg-xx7j bounded. The fallback now dispatches each pattern throughSusPatternsManager._check_regex_pattern, the same bounded matchers (scan-window finders and the executor-timeout path) the enhanced scan uses, so aRecursionErroron 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_patternsno longer iteraterecent_metrics/pattern_statsoutside the lock.get_summary_statsiterated therecent_metricsdeque three times, andget_slow_patterns/get_problematic_patternsiterated thepattern_statsdict, both unlocked whilerecord_metricappends torecent_metricsand inserts/evictspattern_statskeys under the lock; reachable through the publicSusPatternsManager.get_performance_stats(), this raised the sameRuntimeError: deque mutated during iterationclass as therecord_metricrace above (and, forpattern_stats,RuntimeError: dictionary changed size during iteration). All three now snapshot the collection they read underself._lockbefore handing it to the (unlocked, pure) reporting helpers;get_pattern_reportwas 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_authnow 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 raiseValueErroron out-of-range or wrong-type instead of silently landing in the running config. - ReDoS backstop ships.
validate_pattern_safetyruns 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_windowmechanism.bounded_search/bounded_finditerbound the regex search window (not the match length) forPREFIX[^x]*TERMINATORshapes, with no length cap and no N+1 bypass. - New
deserializationcategory (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_REfloor lowered 20 to 12, parameter names now scanned, structured-JSON operator keys, and asurrogateescapedecode 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{{}}/#{},sqlitautology, andcmd_injectionbare$(...)/Log4Shell JNDI patterns. - Telemetry hygiene.
OtelHandlerandLogfireHandlerno longer clobber or destroy host providers;ban_iprefuses loopback and trusted-proxy targets.
Breaking¶
require_authandapi_key_authrequire a verifier. Supply one per route viaverifier=or globally viaSecurityConfig.auth_verifier. Without a resolvable verifier, requests are rejected with 401 (fail-closed). Previously anyBearer/Basicprefix 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; userequire_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_banraiseValueErroron a wrong-type or out-of-range value where a baresetattrpreviously 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_thresholdandauto_ban_durationnow enforcege=1. Construction with0or a negative value now raises; pass1or above.block_cloud_providersaccepts six providers and rejects unknown names.DigitalOcean,Linode, andVultrjoinAWS/GCP/Azure. An unrecognized provider name (the part before an optional:!regionsuffix) now raisesValueErrorat 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=NonestaysNone. Construction, assignment,model_copy, and dynamic-rule rollback/restore no longer silently driftNonetofrozenset(); every real consumer already gates on truthiness.
Added¶
is_ip_allowedandcheck_ip_accessare now exported at the top level asguard_core.is_ip_allowedandguard_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. ReturnsTrue/Falseand records a hit per call. With the defaultendpoint_path=""the Redis key collapses to the HTTP pipeline's global bucket for that IP; pass a non-emptyendpoint_pathfor an isolated budget. Feeds the auto-ban engine whenenable_rate_limit_auto_banandenable_ip_banningare both set. Async and sync mirrors updated identically.SecurityConfig.enable_rate_limit_auto_ban(bool, defaultFalse): opt-in wiring that feeds rate-limit violations into the same auto-ban engine penetration detection uses;"rate_limit"is now a validthreat_ban_configkey. 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 existingenable_ip_banningtoggle; all three restore on rollback.DynamicRules.expires_atis honored. A dynamic rule push with a non-nullexpires_atauto-reverts its config mutations once that time passes. Naive (tzinfo-less)expires_atis treated as UTC. A rule whoseexpires_atis already past on receipt now never activates, instead of driving a revert-then-reapply loop every tick.SecurityConfig.auth_verifier: global default verifier callable forrequire_authandapi_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 onrequest.state.auth_principal. - Dedicated
Detection GateCI 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_safetyhardened. 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_safetyaccepts an optionalmax_content_lengthand probes at the caller's real cap; everySecurityConfig-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'sdetection_max_content_lengthcap'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 underguard_core.sync.detection_engine.scan_window): a drop-in replacement forcompiled.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 supplycompiled,prefix, andterminatorregexes 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
deserializationdetection category (CWE-502) catches Java (rO0AB), Python pickle (gASV/gAWVplus GLOBAL-opcode text markers), PHP (O:/C:/E:), .NETBinaryFormatter(AAEAAAD) plus<ObjectDataProvider, and RubyMarshal(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 realsus_patterns_handler.detect()production entry point. Sync mirror updated in lockstep.
Changed¶
- CI enforces
--cov-fail-under=100and-W error. A coverage drop or a new warning now fails the build instead of accumulating.make lintrunsruff format --check .instead of rewriting in place; usemake fixto 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\salternatives that already consume newlines. User-supplied patterns keepre.IGNORECASE | re.MULTILINEunchanged. Sync mirror updated in lockstep. cmd_injectionflags 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 usingtime.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_REroute through_WINDOWED_PATTERN_FINDERSinsuspatterns_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 sharedbounded_finditerthat an independent fuzz found silently drops matches. Sync mirror updated in lockstep. - Eleven built-in
PREFIX[^x]*TERMINATORpatterns route throughbounded_finditer. Script, style-expression, object, embed, applet, dir-traversal..;, file-inclusion URL, XML entity/DOCTYPESYSTEM, 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_probingpath-probe patterns carried a released quadratic-backtracking DoS since the723e0882"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_patternsnow builds apattern_timeoutthreat entry whentimeout_occurredis 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_rulesandDynamicRuleManager._restore_confignow useSecurityConfig._set_prevalidated, which skips only the_FIELD_REVALIDATORSre-check; the general assignment path is unweakened. The_skip_revalidationflag moved off the instance to acontextvars.ContextVarafter a reproduced thread race and amodel_copydouble-bypass. A pinned test fails if_set_prevalidatedis ever madeasyncor grows anawaitbetweensetandreset. Sync mirror updated in lockstep. ContentPreprocessor.truncate_safelyand the preprocessor now treatdetection_max_body_inspect_bytesas the single memory bound. A hardcoded 262144-byte scan window, aMAX_SHORT_BASE64_SCAN_BYTES = 2_000_000pre-slice, and aMAX_GUNZIP_OUTPUT_BYTES = 8192decompression cap all silently ignored a raiseddetection_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 syntheticcustomweight-1.0 threat when content is still changing on the final iteration.max_decode_iterationsraised from 7 to 16. Sync mirror updated in lockstep._BASE64_REfloor 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%uXXXXIIS-style percent-u escapes are now decoded. A Unicode-lookalike renormalization pass folds decoded separator lookalikes to literal/or\.path_traversalnow compares the decoded view against the raw view and flags when decoding revealed a..[/\\]the attacker tried to hide. A newdir_traversalpattern catches..;/semicolon path-parameter bypass. Sync mirror updated in lockstep. SemanticAnalyzerwas quadratic on plain text. Fourattack_structurespatterns (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_likeis unbounded again but scanned only up to its last>via_tag_scan_window, the general technique for this defect class.extract_tokensno longer spins up tenThreadPoolExecutorinstances per call. Sync mirror updated in lockstep.ban_iprefuses loopback and trusted-proxy targets. A reverse-proxy deployment withtrusted_proxiesunset previously banned127.0.0.1and 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:ValueErrorwithfail_secure=Truereturned HTTP 500 for every request;fail_secure=Falsesilently applied no protection). Sync mirror updated in lockstep.OtelHandlerandLogfireHandlerno 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()checkslogfire.DEFAULT_LOGFIRE_INSTANCE.config._initializedbefore callingconfigure();stop()callslogfire.shutdown()only when this instance configured. Both are idempotent in any order. ALogfireHandler.start()that failedconfigure()no longer gets permanently stuck refusing to retry. Sync mirror updated in lockstep.scripts/unasync.py --checkno 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 printsMISSING:and exits non-zero, an orphan printsORPHAN:. Pinned with a test. Sync mirror updated in lockstep.BehaviorTrackerRedis sink no longer interpolatesendpoint_id,client_ip, orrule.patterninto aKEYSglob. All three now pass throughhashlib.sha256per segment, closing a glob-injection sink and a field-boundary collision. Counting is nowRedisManager.record_sliding_window_hit(one pipelinedZADD/ZREMRANGEBYSCORE/ZCARD/EXPIRE), no keyspace-wideKEYSscan. The sorted-set member isuuid.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_uploadfilename=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_windowtruncates each scan to the last quote character, linear per position with no length cliff.asmx,cer,phpsjoin 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
xsspatterns (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 ison\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}) inxssandfile_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_qslin_scan_form_body,_multipart_text_parts); the other three (honeypot form/JSON, response body) are consistency fixes. A single_sanitize_for_reportinghelper round-trips scanned content throughsurrogateescapethenbackslashreplaceat the six reporting choke points, so a lone surrogate reaching aFileHandlerno 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/$equnder a field key, not numeric range),file_inclusion(JSON value undertemplate/include/tpl/module/layout),ssrf(baremetadata/instance-datahost aliases),cmd_injection(Nodechild_process, PHPassert, Pythonos.exec*),code_injection(__import__('os').system), andeval(equivalents (Function,window['eval'],.constructor.constructor,setTimeout/setIntervalstring 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_pathandheader,template+url_path,deserialization+header.sqli+url_pathmeasured 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_pollutionarbitrary-key pattern,template{{}}/#{}shape-gated pattern,sqlitautology pattern, andcmd_injectionbare$(...)/${...}+ dedicated Log4Shell JNDI pattern.cmd_injectiongains 10 tool names (nmap,socat,msfconsole,msfvenom,certutil,bitsadmin,powershell,pwsh,mkfifo,aria2c) and absolute-path/env-prefixed shell invocation.xssslash-separated handlers (<svg/onload=) andssrfuserinfo (http://x@localhost/) detected.sqligainsCREATE, stackedSELECT...FROM/REPLACE INTO, andEXEC xp_/sp_system-proc patterns. Explicit-scheme RFI (?page=http://attacker/shell.php) is a dedicatedfile_inclusionpattern. 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/$ltewith a numeric literal) are not flagged as NoSQL injection: they are indistinguishable from legitimate range queries. Auth-bypass shapes ($nenull or boolean,$gt "",$regex,$where,$exists,$in) are flagged. Sync mirror updated in lockstep. _LDAP_WILDCARD_EQUALS_RErepurposed 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 atn=40) are all closed. The_ldap_wildcard_chain_is_injectionvalidator is split into three functions to stay under the xenon ceiling. Sync mirror updated in lockstep._LDAP_ATTR_EXTENSIBLE_MATCH_REhad an unbounded, uncancellable event-loop hang. The:dn/[\w.-]+alternation branches overlap completely, giving2^nequivalent parses; a 77-byte body hung the event loop for 7.76s. The redundantdnbranch is removed; a killable-subprocess regression test pinsdetect()returns within 2 seconds on"*)(a" + ":dn"*30 + "X". Sync mirror updated in lockstep.sqliWHERE-clause corroboration reverted; tautology pattern added._WHERE_CLAUSE_REno longer corroborates on a placeholder-shaped value; a new_SQLI_TAUTOLOGY_RE(\b(?:OR|AND)\s*(\d+|'[^']*'|"[^"]*")\s*=\s*\1\b) detects1 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 beforeGLOBALpreviously defeated detection; a bounded, non-executing structural validator (_pickle_global_prefix_is_opcode_stream) replays the up-to-32-byte prefix window against apickle._Unpicklersubclass whosefind_class/get_extension/persistent_loadunconditionally raise.FRAMEis handled directly in the dispatch loop (the predicted short-read cause was wrong; the real defect was anAttributeError). The validator'sread/readline/readintonow 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-Noneresult, so{"username":{"$ne":null}}nested one level down was invisible onquery_param/header. The early return now happens only whendetectedis true; a negative JSON result falls through to the raw scan. Sync mirror updated in lockstep. block_cloud_providers's:!regioncarve-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, theKeyErrorwas 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-testisolation andMakefilecache-cleanup fixes. Four entrypoints hardcoded a sharedREDIS_PREFIXthat shadowed the PID-scoped default and let one suite's teardown wipe another's keys;Makefileandcompose.ymlnow passREDIS_PREFIXthrough only when set, and CI jobs drop the hardcoded value.make integration-testnow runs the async and sync OTel integration tests as separateuv run pytestinvocations so the process-global OTel providers do not collide. The 20 Makefile recipes that pipedfind .intoxargs rm -rfnow share oneCLEAN_CACHESvariable that hands each matched path torm -rfas its own argument regardless of embedded whitespace. Sync mirror updated in lockstep.RateLimitManager.reset()now clearsredis_handler. A previously-attached Redis handle no longer survivesreset()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.loadson attacker-shaped bytes. It now loads through a_BlockingUnpicklerwhosefind_classraises 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 asguard_core_versionwith no operator action required. The pre-existingagent_guard_versionfield is unchanged and still carries the framework wrapper version, which is operator-supplied; because adapters declareguard-corewithout a version constraint, the wrapper version cannot identify which guard-core is actually installed, andguard_core_versioncan. BoundedBodyReaderandSyncBoundedBodyReader(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 alongsideGuardRequestto let detection inspect a size-capped prefix of a request body that has no usableContent-Length(for example chunked transfer-encoding), without reading or buffering the rest. It is exported fromguard_core.protocols.__all__/guard_core.sync.protocols.__all__and reachable asguard_core.BoundedBodyReader/guard_core.sync.SyncBoundedBodyReaderfrom the start.BoundedResponseBodyReader(guard_core/protocols/response_protocol.py) and its blocking mirrorSyncBoundedResponseBodyReader(guard_core/sync/protocols/response_protocol.py): the response-side counterpart ofBoundedBodyReader,async def read_body_prefix(self, max_bytes: int) -> bytes, that an adapter implements alongsideGuardResponseto letreturn_patternbehaviour 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 fromguard_core.protocols.__all__/guard_core.sync.protocols.__all__and reachable asguard_core.BoundedResponseBodyReader/guard_core.sync.SyncBoundedResponseBodyReader. Two newSecurityConfigfields control it:behavior_scan_response_body: bool(defaultFalse) gates response-body reading forreturn_patternrules entirely, andbehavior_max_response_body_inspect_bytes: int(default262144, 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 ofread_body_prefixmust 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 asBoundedBodyReader'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(default3.0seconds, range0.0-30.0exclusive of zero): the wall-clock boundasyncio.wait_forapplies, in the ASYNC guard_core tree only, to every adapter call guard-core makes throughBoundedBodyReader.read_body_prefix,BoundedResponseBodyReader.read_body_prefix, and the plainGuardRequest.bodyread. 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
SecurityConfigwith aglobal_behavior_rulesreturn_patternentry whose pattern is notstatus:-prefixed whilebehavior_scan_response_bodyisFalsenow raisesValueErrornaming 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_scanhelper.
Fixed¶
RequestValidator.is_path_excludedmatchedexclude_pathswith a plainstr.startswithand no normalisation or path-boundary check, andBypassHandler.handle_passthroughreturnedcall_next(request)the moment it matched, before the client IP was even extracted. Because/staticships in the defaultexclude_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/credentialsand/static/../../../root/.ssh/id_rsawere all treated as excluded, and a banned IP still reached them. Present in every released version. Path matching now lives in a pureguard_core.core.validation.path_matchingmodule 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_pathsentry 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_valuehelper rejects such entries with aValueErrornaming the offending value, and is invoked from every placeexclude_pathscan be set: thefield_validatorat construction,SecurityConfig.__setattr__for a direct runtime assignment toexclude_paths, and an overriddenmodel_copywhen itsupdatetouchesexclude_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.SecurityConfigdeliberately leavesvalidate_assignmentunset: 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_excludedevent was emitted on every request to an excluded path, uncached and unsampled. Orchestrator liveness probes hitting/healthz,/health,/metrics,/readyand/liveon a timer therefore generated one telemetry event per probe, indefinitely. Emission is now throttled through aTTLCache(maxsize=1000, ttl=300)keyed on the normalised path, mirroring the existing throttle onsecurity_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_pathscaches 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 whatexclude_pathsactually contains -- a whole-value reassignment or a size-preserving in-place edit such asconfig.exclude_paths[1] = "/other"-- is picked up on the very next request regardless of whether anything else onconfigchanged.escalate_suspicious_if_threatis renamed toescalate_identity_violation(guard_core/core/checks/helpers.py). Separately,IpSecurityCheck.check()computedrequest.state.is_whitelisted-- the flagescalate_identity_violationand 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_accessbefore calling_check_route_ip_restrictions, and leaving it set while that route-level check ran. A route that blocks an IP through its ownroute_config.ip_blacklistis a decision_resolve_global_ip_accessnever evaluated, but with the flag already sitting onrequest.statefrom the global check moments earlier,escalate_identity_violation's ownis_whitelistedguard saw it asTrueand returned immediately: a route-level block for an IP that also happens to sit on the globalconfig.whitelistwas silently never escalated, regardless of the payload --suspicious_request_countsstayed empty,ban_ipwas never called, and noEVENT_PENETRATION_ATTEMPTwas ever emitted for it, even for a real SQLi payload.IpSecurityCheck.check()no longer writesrequest.state.is_whitelistedbefore 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 reachesescalate_identity_violationwith 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 noip_whitelist/ip_blacklistof its own -- sets itTrue. Tests now cover the same IP appearing in bothconfig.whitelistandroute_config.ip_blacklist(escalates normally) and in bothconfig.whitelistandroute_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_violationincrementssuspicious_request_countsand checksthreat_ban_configusing the real detectedthreat_categoriesfromget_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, oruser_agent) is still attached to the emittedpenetration_attemptevent asviolation_categoryfor observability, but does not feed the ban counters.SuspiciousActivityCheckno longer mutatesmiddleware.suspicious_request_countsthrough its own private method (_increment_per_category), which had no lock, no_MAX_TRACKED_SUSPICIOUS_IPScap, and no LRU touch-on-access; it now calls the same shared_increment_suspicious_countshelperescalate_identity_violationuses, so there is exactly one writer tosuspicious_request_countsin 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_countsnow 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, noawaitinside it) is guarded by athreading.Lockin both the async and sync trees -- loop-agnostic, since it never needs to be released across anawait-- so_increment_suspicious_countsis a plaindefin the async tree too, notasync def.get_cached_detection_resultcaches the per-requestDetectionResultonrequest.state, keyed on the identity of both therequestand theroute_configobject it was computed for (cached[0] is request and cached[1] is route_config), sodetect_penetration_attemptruns 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 everylogger.exception(...)call (both the top-level failure log and theip_ban_failedevent-bus report) in its owntry/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_rangesin 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_rangesnow 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, anhrefmatch, and a plain-textServiceTags_Public_*.jsonURL match -- and validates every candidate URL against the same allowlist before using it: the scheme must behttpsand the parsed hostname must equaldownload.microsoft.comexactly, so a lookalike host such asdownload.microsoft.com.evil.comis 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 passesallow_redirects=False(the prior release's inline download usedaiohttp's, and in the sync treerequests's, default of following redirects), and any 3xx response now raises aValueErrorbeforeraise_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 datedServiceTags_Public_*.jsonURL 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 singleexcept Exceptionspanned 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_tagsnow retries onlysession.getraising (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 retriedtry/exceptand 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_failureis renamed totest_fetch_azure_ip_ranges_bad_status_is_not_retriedto match, alongside a newtest_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 amodel_validator(mode="after"), which -- sinceSecurityConfigdoes not setvalidate_assignment-- only ever ran at construction; a runtime assignment such asconfig.blocked_countries = ["CN"]afterwhitelist_countrieswas already set never re-checked the shadow condition at all.SecurityConfig.__setattr__now re-runs the same check when eitherblocked_countriesorwhitelist_countriesis 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 tofrozenset[str]first, since country fields are stored asfrozenset[str]and comparing the raw incoming value directly against the stored frozenset would re-warn every time the same countries were reassigned as alist/tuple/setrather than afrozenset. The truthiness check (self.whitelist_countries and self.blocked_countries) runs before the field is mutated andrevisionis 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 abasewith a non-emptywhitelist_countriesproduced a copy with both lists populated and no warning, sincemodel_copyneither runswarn_country_allowlist_shadows_blocklist(a construction-only model validator) nor goes through__setattr__.model_copynow re-runs the same check via_warn_country_allowlist_shadows_blocklistwhenever itsupdatetoucheswhitelist_countriesorblocked_countries, evaluated against the copy's final state, mirroring theexclude_pathshandling the override already had for its own field. Unlike the__setattr__path, this one is not deduplicated against the base's prior value: amodel_copycall 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=, otherupdatekeys, subclass identity, and the plain no-updatecall are unaffected.- On a request with no usable
Content-Length(for exampleTransfer-Encoding: chunked), the prior release's fail-closedContent-Lengthgate (_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_lengthparses a presentContent-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 todetection_max_body_inspect_bytesthrough the newBoundedBodyReadercapability -- whenContent-Lengthis absent, so a chunked request can now be inspected up to the cap instead of being skipped entirely. Both branches (Content-Lengthpresent or absent) now share onerequest.statecache, keyed on request identity the same wayget_cached_detection_resultis, so two independent readers on the same request in the same pipeline run (for example@guard.honeypot_detection([...])'s validator andSuspiciousActivityCheck's body scan) both see the same prefix -- and paybody_read_timeoutat 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'sread_body_prefix/bodyreturns something other thanbytes, 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 thanbytes) is never written torequest.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 transientConnectionResetErroron 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/bodycall: a stalled SSE producer, a long-poll that never yields, or a buggy adapter implementation left_safe_readawaiting 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 inasyncio.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'sTTLCacheon the response side; the request side already returnsNonesilently for a raising reader and continues to). This bound is configurable viaSecurityConfig.body_read_timeout(default 3.0 seconds) and applies uniformly toBoundedBodyReader.read_body_prefix,BoundedResponseBodyReader.read_body_prefix, and the plainGuardRequest.bodyread, 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_readinguard_core/sync/utils.pyhands each read attempt to its owndaemon=Truethread and joins it for up to the remainingtimeoutbudget; thread growth is capped by athreading.Semaphore(sync_body_read_max_concurrent)(default 64) that the caller must acquire, also bounded bytimeout, before the thread is even started.SyncGuardRequest.body,SyncBoundedBodyReader.read_body_prefix, andSyncBoundedResponseBodyReader.read_body_prefixall route through it, and bothSecurityConfig.body_read_timeoutandSecurityConfig.sync_body_read_max_concurrentapply 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(withroute_config=None, so no per-route override participates) for a request markedguard_exclusion_scopedbyBypassHandler.handle_passthrough, after the dynamicip_ban_managercheck. 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_restrictionsgained anescalatekeyword, and the exclusion-scoped call passesescalate=False, so a block on an excluded path never runs penetration detection to categorise it forthreat_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 aSecurityCheck.enforced_on_excluded_paths: ClassVar[bool] = Falseclass attribute thatSecurityCheckPipeline.executereads directly off each check instance (TrueonRouteConfigCheck,IpSecurityCheck, andRateLimitCheck, 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 fromDEFAULT_CHECK_CLASSESwith the attribute set matches exactly those three.guard_core.models.SecurityConfig.dynamic_rule_intervalhad no floor, while theAgentConfigfield it is forwarded to (guard_agent.models.AgentConfig.dynamic_rule_interval) enforcesge=60. Setting it below 60 did not raise by default, sinceSecurityConfigandAgentConfigare validated independently and agent construction only raises on a rejected value whenagent_strict=True; otherwise a too-low value silently disabled the entire agent integration instead of erroring.dynamic_rule_intervalnow also enforcesge=60, matching the floorAgentConfigalready requires. Every otherSecurityConfigfield forwarded toAgentConfiginto_agent_config()was checked against the installedAgentConfig's ownFieldconstraints for the same class of mismatch:agent_status_intervalalready carriesge=60, le=86400, at least as strict asAgentConfig.status_interval'sge=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, andagent_high_watermark_ratiocarry no bound on either side, so there is nothing for either side to drift from.dynamic_rule_intervalwas the only mismatch found.- The
file_inclusionprotocol-relative-URL pattern inguard_core/handlers/suspatterns_handler.pymatched any//hostsubstring, 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 normalscheme://URL) no longer matches; a scheme-less protocol-relative reference such as//evil.com/shell.txtor?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, andhttp://localhost:8080/admin) were detected only because the over-broad file-inclusion pattern happened to match their//, not because the dedicatedssrfpattern actually matched them, it never did. Thatssrfpattern'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 ahost:portsuch aslocalhost:8080) can satisfy; every "detection" credited to it for a real dotted-quad private IP or alocalhost/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:portbefore the boundary, sohttp://169.254.169.254/latest/meta-data/,http://localhost:8080/admin, and real10.x.x.x/172.16-31.x.x/192.168.x.xtargets are now matched by thessrfcategory itself. The attack-simulation benchmark'sdetection_rateis unchanged (0.8568), now for the correct reason. - The
cmd_injectionshell-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_patternguarded response-body access withhasattr(response, "body").hasattrswallows exceptions, and a framework adapter'sGuardResponse.bodyis a property that raisesAttributeErrorfor 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. Everyjson:,regex:, and bare-substringreturn_patternrule evaluated against such a response therefore silently returnedFalse, 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 readresponse.status_codeand never touch the body, were never affected._check_response_patternno longer touches.bodyorhasattrfor the body-reading formats at all: it now requires the response to implement theBoundedResponseBodyReadercapability, detected with an explicitisinstancecheck rather than a property probe (safe against the same raising-property problem, since anisinstancecheck against aruntime_checkableProtocolnever invokes a method member, only a property member), gated by the opt-inSecurityConfig.behavior_scan_response_bodyflag (defaultFalse, so upgrading changes nothing until it is turned on) and bounded bySecurityConfig.body_read_timeout(see above). When the flag is off, the capability is absent,read_body_prefixraises or times out, or it returns something other thanbytes,_check_response_patternreturnsNone: a could-not-evaluate outcome distinct fromFalse, logged through the same throttledTTLCache(maxsize=1000, ttl=300)this warning already used (keyed by pattern, at most once per five minutes per distinct pattern).track_return_patternfoldsNoneinto "no occurrence recorded", the same asFalse, so a rule that cannot be evaluated still never reports a match it did not observe -- it just also never records a false one.BehaviorTrackerdoes not cache the response-body prefix it reads. An earlier iteration cached it in aweakref.WeakKeyDictionarykeyed on the response object itself, on the theory that multiplereturn_patternrules 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 onereturn_patternrule, 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) raisedTypeErroron theweakref.ref()the dict requires, which the caller's outerexcept Exceptionswallowed into a silent, permanent, process-lifetimeFalse("no match") for everyreturn_patternrule evaluated against that adapter's responses, logged only via an untetheredlogger.warning/logger.erroron every single call rather than the same throttledTTLCachethis 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. Eachreturn_patternrule 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 bothvalidate_global_return_pattern_body_scanand the decoration-time check in@security.return_monitor()/@security.behavior_analysis()(see Added, above): areturn_patternrule with a body-reading pattern could be added to an existingSecurityConfigat runtime whilebehavior_scan_response_bodywasFalseand would silently never fire -- the exact rule shape construction already rejects, entering through a door neither validator was wired to.global_behavior_rulesis nowtuple[BehaviorRuleConfig, ...]instead oflist[BehaviorRuleConfig](see Behaviour changes, below), so.append/.extend/.insert/slice-assignment all raiseAttributeError/TypeErrorimmediately: 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 = (...)) andmodel_copy(update={"global_behavior_rules": (...)})-- now re-run the same check construction uses, throughSecurityConfig.__setattr__and themodel_copyoverride respectively, the same mechanismexclude_pathsand the country fields already use in both methods.behavior_scan_response_bodyis covered symmetrically: reassigning it toFalsewhileglobal_behavior_rulesalready holds a body-readingreturn_patternrule 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, andmodel_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 individualBehaviorRuleConfigalready inside the tuple in place (config.global_behavior_rules[0].pattern = "..."):BehaviorRuleConfigremains an ordinary, non-frozen Pydantic model, and nothing in the engine mutates one in place today, but closing that residual gap would needmodel_config = ConfigDict(frozen=True)onBehaviorRuleConfigand is left for a follow-up. Auditing every otherSecurityConfiglist/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 formatfield_validators that raise),threat_ban_config(category-membershipfield_validator, raises),muted_event_types,muted_metric_types,muted_check_logs,enabled_detection_categories(membershipfield_validators, raise), andblock_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) andexclude_paths/global_behavior_ruleswere, 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(thegeo_ip_handler-requirement check, distinct from the shadow-blocklist check this release closes formodel_copy) had the same__setattr__-reassignment gap the shadow check had before the prior release --config.blocked_countries = [...]on a config with nogeo_ip_handlerand noipinfo_tokenset 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_ruleswas: an immutable type plus the identical__setattr__/model_copyre-validation wiring.whitelist(tuple[str, ...] | None, keeping itsNone"no whitelist" sentinel),blacklist, andtrusted_proxies(bothtuple[str, ...]) replacelist[str], so.append()/.extend()/.insert()/slice-assignment now raiseAttributeError/TypeErrorinstead of mutating an unvalidated list.enabled_detection_categories,muted_event_types,muted_metric_types, andmuted_check_logsreplaceset[str]withfrozenset[str], so.add()/.discard()/.update()raise the same way.threat_ban_configreplacesdict[str, ThreatBanConfig]withtypes.MappingProxyType[str, ThreatBanConfig]-- the standard library's read-only mapping view, since Python has no built-in frozen dict -- soconfig.threat_ban_config["xss"] = ThreatBanConfig(...)now raisesTypeError("'mappingproxy' object does not support item assignment") instead of silently bypassing the category-membership checkvalidate_threat_ban_configalready enforced at construction.block_cloud_providersreplacesset[str] | Nonewithfrozenset[str] | None, closing the same in-place-mutation gap, andvalidate_cloud_providersnow raisesValueErrornaming any entry whose provider name (the part before an optional:!regionsuffix) is notAWS/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 leftGPCtraffic completely unblocked with no error, warning, or log line anywhere. Every one of the nine fields'field_validators moved tomode="before"so the identical coerce-and-validate function backs both the constructor path (via Pydantic) and the new assignment/model_copypath (called directly), the same shared-function shape_validate_exclude_paths_valuealready established:config.whitelist = ["not-an-ip"]andbase.model_copy(update={"threat_ban_config": {"bogus": ThreatBanConfig(threshold=1, duration=1)}})both now raise the identicalValueErrorconstruction would, and a rejected reassignment leaves the field andrevisionunchanged, the same no-partial-state guaranteeexclude_paths/global_behavior_rulesalready provide. validate_geo_ip_handler_existsis closed the same way the country-shadow check was closed in the prior release:SecurityConfig.__setattr__andmodel_copynow re-run it wheneverblocked_countries,whitelist_countries,geo_ip_handler, oripinfo_tokenchanges after construction, evaluating the merged final state the same way construction does.config.blocked_countries = ["US"]on a config with nogeo_ip_handlerand noipinfo_tokennow raisesValueError("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 andrevisionare left unchanged on the raise. The construction-time convenience of auto-building anIPInfoManagerfrom a setipinfo_tokenis preserved on the assignment path too: reassigningblocked_countries/whitelist_countrieson a config that already carriesipinfo_token(and nogeo_ip_handler) auto-constructs the handler the same way construction does, and assigningconfig.geo_ip_handler = Nonewhile 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
ssrfcategory 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/, andhttp://0x7f.1/all resolve to127.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 BSDinet_atonone-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's100.100.100.200/32. Cloud metadata coverage had been AWS-only (169.254.169.254); GCP'smetadata.google.internalandmetadata.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 into0.0.0.0/8and the entire TCP port range would otherwise be reported as an SSRF target --redis://6379,grpc://50051, andamqp://5672are connection strings, not addresses, and are not flagged. The one value excluded from that exclusion is the canonical decimal form of0.0.0.0itself:http://0/andhttp://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 witherrors="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'scomputeMetadata/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 at0.5after 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 their0xprefix specifically; a token that merely looks like hex (an even-length run of0-9a-fwith no0xprefix) 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
SusPatternsManageris compiled withre.IGNORECASE | re.MULTILINE(guard_core/handlers/suspatterns_handler.py, both thePatternCompiler-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 acrossrecon,cms_probing,sqli,cmd_injection, andssrfused 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/versionwas reported asrecon; a workspace role list containing a line reading exactlyadministratorwas reported ascms_probing; a migration note ending a line inORDER BY 2was reported assqli; a shell-usage example ending a line inecho 'debug' #was also reported assqli; and a deployment-script excerpt containing the linesh -x deploy.shwas reported ascmd_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 ofrecon/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 explicitscheme://host/prefix (cms_probing'swp-admin/administrator/xmlrpcpattern, 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'sORDER BY/comment patterns now also match immediately after a=,?, or&even mid-body;cmd_injectiongained a pattern for a shell invocation with the-cflag 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 twossrfpatterns that also use bare^/$are left as they are: their adjacent\salternative already consumes a newline as a boundary regardless of theMULTILINEflag, so anchoring them would have been a no-op. - Separately, the
reconcategory'smanagement/system/version/config_dump/credentialsprobe 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
ldapcategory 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_rulesis nowtuple[BehaviorRuleConfig, ...]instead oflist[BehaviorRuleConfig](see Fixed, above). Code that previously called.append()/.extend()/.insert()on it, or assigned to a slice, now gets an immediateAttributeError/TypeErrorinstead 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
SecurityConfigfields change type (see Fixed, above).whitelist: tuple[str, ...] | None,blacklist: tuple[str, ...],trusted_proxies: tuple[str, ...](werelist[str]);enabled_detection_categories,muted_event_types,muted_metric_types,muted_check_logs: frozenset[str](wereset[str]);threat_ban_config: types.MappingProxyType[str, ThreatBanConfig](wasdict[str, ThreatBanConfig]);block_cloud_providers: frozenset[str] | None(wasset[str] | None). Code that mutates one of these in place --config.whitelist.append(...),config.muted_event_types.add(...),config.threat_ban_config["xss"] = ...-- now raisesAttributeError/TypeErrorinstead 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 plainlist/set/dictis 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_providersadditionally changes behavior independent of its type: an unrecognized provider name that was previously dropped silently now raisesValueError, 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_countriescan no longer be reassigned (or set viamodel_copy) into a state with no way to resolve a country, i.e. nogeo_ip_handlerand noipinfo_token(see Fixed, above). A deployment that reassigns these fields at runtime (for example fromDynamicRuleManager) without ageo_ip_handleralready configured will now get aValueErrorwhere it previously got silence and an inert country check; configuregeo_ip_handler(or the deprecatedipinfo_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 requirementvalidate_geo_ip_handler_existsalready enforced.- Identity-block escalation (route/global IP restrictions,
ip_blocked, and blocked user-agents) no longer contributes toauto_ban_threshold/threat_ban_configon its own; it only does so when the same request is also flagged by penetration detection, and the categories it counts towardthreat_ban_configare 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 inblocked_countries/blacklistinstead, since a ban is no longer a side effect of being blocked often. request.state.is_whitelistednow 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_restrictionsactually runs, and is never set from a route-levelip_blacklist/ip_whitelistdecision, which does not evaluate the global whitelist at all. Any direct consumer ofrequest.state.is_whitelisted(as opposed to the checks that already read it throughgetattr(..., False)) should use the same default-Falseaccess pattern._increment_suspicious_countsis a plaindefguarded by athreading.Lockin both the async and sync trees (notasync def); any direct caller must call it synchronously, withoutawait.exclude_pathsno longer bypasses the security pipeline entirely.BypassHandler.handle_passthroughnow marks a matched requestguard_exclusion_scopedonrequest.stateand returnsNoneinstead of callingcall_nextdirectly, so the request still reachesSecurityCheckPipeline.execute. There, only theroute_config,ip_security, andrate_limitchecks run for an exclusion-scoped request (SecurityCheck.enforced_on_excluded_paths, see above); every other check, includingsuspicious_activity(payload detection), is skipped, so an excluded path is still cheap. Concretely: an already-banned IP is still blocked byIpSecurityCheck._check_banned_ipon an excluded path, a statically blacklisted or whitelisted IP, a blocked country, or a blocked cloud provider is likewise still enforced byIpSecurityCheck._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 triggersescalate_identity_violation's detection-based categorisation either.BehavioralProcessortreats 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'sban_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 afrequencybehavioral 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 whenguard_exclusion_scopedis set. Applications that relied onexclude_pathsmaking a path invisible to a standing IP ban, a static blacklist/whitelist/country/cloud restriction, or rate limiting should reconsider that path's inclusion inexclude_pathsnow that all of them are enforced there.- Response-body-reading
return_patternrules (json:,regex:, bare-substring) are opt-in:behavior_scan_response_bodydefaults toFalse, 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 inglobal_behavior_ruleswill now fail to construct itsSecurityConfiguntil it either setsbehavior_scan_response_body=Trueor replaces the rule with astatus:pattern; the same shape via@security.return_monitor()/@security.behavior_analysis()now raises the identicalValueErrorat decoration time. The removedhasattr(response, "body")codepath (see above) never matched anything for a genuinely streaming response, whose.bodyraises 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.bodyis a plain, non-raising, already-materialized attribute: that shape matched correctly under the removedhasattr/.bodycodepath, and does not match under this release -- opt-in flag on or not -- until the adapter also implements the newBoundedResponseBodyReader.read_body_prefixcapability. This is a lockstep-upgrade requirement across the ecosystem. guard-core, fastapi-guard, flaskapi-guard, and djapi-guard are separate repositories; every adapter pinsguard-corewith 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 implementsBoundedResponseBodyReader, silently drops everyreturn_patternbody rule for that adapter -- even withbehavior_scan_response_body=Trueexplicitly set -- with no error and no signal beyond the pre-existing throttled could-not-evaluate log line.status:patterns, which read onlyresponse.status_codeand 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 plainGuardRequest.body-- that previously could hang indefinitely on a stalled adapter now fails closed (treated the same as a raising reader) afterSecurityConfig.body_read_timeout(default 3.0 seconds). The SYNC tree does not:SyncGuardRequest.body,SyncBoundedBodyReader.read_body_prefix, andSyncBoundedResponseBodyReader.read_body_prefixblock the calling thread for as long as the adapter takes andbody_read_timeouthas no effect there; bound a stalled sync adapter read with the WSGI server's own request timeout instead (gunicorn--timeout, uWSGIharakiri).
Documentation¶
detection_max_body_inspect_bytes's field description, theBoundedBodyReader/SyncBoundedBodyReaderprotocol docstrings, anddocs/api/protocols.md/docs/configuration/detection-tuning.mdstate plainly that bounded body inspection only ever scans the leadingdetection_max_body_inspect_bytesbytes 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/SyncBoundedBodyReaderdocstrings spell out that the memory bound is adapter-cooperative only: guard-core'sprefix[:max_bytes]slice trims whatread_body_prefixalready returned, but cannot stop an implementation from buffering more thanmax_bytesinternally before returning it. Implementations must not buffer more thanmax_byteswhile 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, anddocs/internals/behavioral.mddocument theBoundedResponseBodyReader/SyncBoundedResponseBodyReaderprotocol, thebehavior_scan_response_body/behavior_max_response_body_inspect_bytes/body_read_timeoutfields, 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'sGuardResponse.bodyrow, 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.mdanddocs/configuration/detection-tuning.mdnow state plainly thatbody_read_timeoutbounds 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 anddocs/release-notes.mdcall out, in bold, that upgrading guard-core alone does not restore a previously-workingreturn_patternbody rule for a non-streaming response until the adapter also shipsBoundedResponseBodyReadersupport, naming fastapi-guard, flaskapi-guard, and djapi-guard explicitly as the lockstep-upgrade requirement this is.docs/api/ban-config.mddocuments a known limitation innormalize_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/passwdnormalises to itself andpath_is_excludedreports it as excluded when/staticis configured inexclude_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;paramsbefore 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), whichnormalize_url_pathpreserves 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.mdcarried a per-fieldLinecolumn againstguard_core/models.pythat 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 agrep -none-liner given for anyone who wants a field's current line. While re-verifying the table against source, two fields present inSecurityConfigbut 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 thedetectiondomain subtotal is corrected from 16 to 18 to match.docs/api/models.md,docs/api/behavior-rules.md,docs/configuration/security-config.md, anddocs/internals/api-surface-audit.mdare corrected:global_behavior_rulesand the nine fields above no longer show their pre-3.12.0list/set/dicttypes,block_cloud_providers's validator entry no longer says it "silently filters", andvalidate_geo_ip_handler_exists's entry now notes it also runs on reassignment andmodel_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 withbody_read_timeout(budgeted by the newsync_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, anddocs/configuration/detection-tuning.mdare corrected to say so.docs/internals/detection-engine.mdsaidextract_attack_regions()scans for "21 attack indicator patterns"; four more were added toContentPreprocessor.attack_indicatorsalongside the fixes above (the shell metacharacters`,\$\(, and[;&|], so truncation pastmax_content_lengthno longer drops the characters acmd_injectionsignature 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_rangeswas 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 recurringFailed to fetch Azure IP rangeserror, on every refresh, for deployments whose egress todownload.microsoft.comcould 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 toServiceTagsfiles when the href-wrapped form is not found, covering pages that embed the link in JavaScript or data attributes without matching unrelated.jsonlinks 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
SuspiciousActivityCheckran, so a probe that was also a penetration attempt never incrementedsuspicious_request_counts, never escalated to a persistent ban, and received per-request 403s indefinitely with noban_ipand the threat intel lost. The three identity-blocking branches now call a shared side-effect-onlyescalate_suspicious_if_threathelper before the 403 return, mirroringSuspiciousActivityCheck'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 letsSuspiciousActivityCheckrun. The helper wraps its body in a try/except so aban_ipfailure (for exampleauto_ban_durationexceeding 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 gatedetect_penetration_attemptuses to honourdetection_max_body_inspect_bytes(added in v3.2.0), returnedFalsewhenContent-Lengthwas missing or malformed, so a request sent withTransfer-Encoding: chunked(or a syntactically invalidContent-Length) bypassed the body-inspection cap entirely and triggered an unboundedrequest.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 returnsTrue(fail-closed) whenContent-Lengthis 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 inguard_core/decorators/advanced.py(the@guard.honeypot_detection([...])path), which had the same unbounded body-read class of bug on itsPOST/PUT/PATCHvalidation branch; both the async and sync trees are fixed. See GHSA-xv6g-49vj-7w9c.
Added¶
- Two new
SecurityConfigfields make the statistical-anomaly detector tunable:detection_anomaly_emission_cooldown(default60.0, bounds 1.0 to 3600.0) sets the minimum seconds between anomaly events for the same pattern, anddetection_min_samples_for_anomaly(default30, bounds 10 to 1000) sets the minimum samples recorded for a pattern before statistical-anomaly detection engages.anomaly_emission_cooldownwas already aPerformanceMonitorconstructor parameter but was never wired fromSecurityConfig, so it was fixed at 60 seconds; the sample floor was a hardcodedlen(recent_times) < 10check. Both are now passed from config insuspatterns_handler._apply_enhanced_config. Raise either to reduce noise and false fires on low-traffic apps. - guard-core now emits a
UserWarningwhen bothwhitelist_countriesandblocked_countriesare configured, becauseblocked_countriesis silently inert under a non-emptywhitelist_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_anomalyevents 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 withdetection_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
SecurityConfigfields exposeAgentConfigsettings 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 ofAgentConfig's 24 fields; adapters passSecurityConfigstraight 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 toNone, andto_agent_config()omitsNonevalues from theAgentConfig(...)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. SecurityConfignow logs a warning naming any unrecognised constructor keyword. Pydantic's defaultextra="ignore"silently discarded them, so a typo such asagent_compresion_enabled=Falsewas accepted, had no effect, and produced no diagnostic. Amodel_validator(mode="before")inspects the raw input before Pydantic drops unknown keys and logs each one throughguard_core.models.extradeliberately remainsignore: 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_errorwas never forwarded toAgentConfig, so two of the four stages its own field description documents could never fire. The description namesagent_init,geoip,transport_sendandencryptionas the possiblestagevalues, but guard-core emits onlygeoip;transport_sendandencryptionare emitted inside guard-agent, which never received the hook. The callback is now forwarded under the same omit-if-Nonerule as the ten fields above, with no separateagent_on_errorfield, since one hook receiving all four stages is the documented design. guard-core's own consumption ofon_erroris unchanged.
Behaviour changes¶
- Applications that already set
SecurityConfig.on_errorwill begin receiving agent side errors through it, under thetransport_sendandencryptionstages. This was always the documented contract; the hook simply never reached the agent. A callback that assumes it only ever seesgeoipshould 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_pipelinenow filtersDEFAULT_CHECK_CLASSESthrough it before instantiating anything, so a deployment only pays for the checks its configuration can actually trigger. The base implementation returnsTrue, so any check that does not override it keeps running unconditionally; elimination is strictly an optimization, never a security decision, and everyapplies_toimplementation returnsTrueon any uncertainty about route configuration.enable_dynamic_rules=Truekeeps every check whose predicate depends on a flagDynamicRuleManagercan mutate at runtime, regardless of every other flag.build_default_pipelinereads the registered per-route decorator configuration throughmiddleware.guard_decoratorto 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_coreno longer forcesaiohttp,redis, ormaxminddbintosys.modules.guard_core/decorators/base.pymoved itsBehaviorTrackerimport from module scope intoBaseSecurityDecorator.__init__, andguard_core/handlers/__init__.pyandguard_core/__init__.py(mirrored by hand intoguard_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 existingfrom guard_core... import Xcall site,getattr(), anddir()keeps working.IpSecurityCheck,SuspiciousActivityCheck,CloudProviderCheck, andCloudIpRefreshChecknow import their handler singleton inside__init__instead of at module scope, so a block phase 1'sapplies_toeliminates from the pipeline never imports its handler at all.SecurityCheck.requires: ClassVar[tuple[str, ...]] = ()names the packaging extra(s) a block needs;CloudProviderCheckandCloudIpRefreshCheckset it to("cloud",). Three new optional-dependency extras package the same split:redis,cloud(aiohttp+requests), andgeo(maxminddb). All three stay in the basedependencieslist 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.SecurityConfiggained a model validator that callsimportlib.util.find_spec(never a bareimport, so the check itself imports nothing) wheneverenable_redis,block_cloud_providers, or a geo-IP handler/country rule is configured, and raises aValueErrornaming the missing extra's install command (e.g.pip install guard-core[geo]) instead of letting the feature fail later with a rawImportError.SecurityConfiggains a private, monotonically increasing revision counter (a Pydantic v2PrivateAttr, so it is absent frommodel_fields,model_dump(), equality, and the constructor) that an overridden__setattr__bumps on every attribute assignment.build_default_pipelinenow handsSecurityCheckPipelinetheSecurityConfigit built from plus a rebuild closure overDEFAULT_CHECK_CLASSES/applies_to;SecurityCheckPipeline.execute()compares the config's current revision against the revision it last built at and only calls the closure -- reassigningself.checksto 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 anddocs/adapters/testing.mduse directly, is unaffected: without aconfig/rebuild_checksargument the pipeline never rebuilds, exactly as before this change.SecurityCheckPipeline._rebuild_if_stale()now also tracks a size signature --len()(or0forNone, with no allocation) onblocked_user_agents,block_cloud_providers, andendpoint_rate_limits, the only mutable containers anyapplies_topredicate reads, and the only shape that matters since each one is consumed asbool(...), 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 asconfig.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 owncontainer_fields: ClassVar[tuple[str, ...]]next to itsapplies_to, andfactory.WATCHED_CONTAINER_FIELDSis the union of that attribute acrossDEFAULT_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: threelen()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 aRouteConfigRevisioncounter owned byBaseSecurityDecorator, on every attribute assignment made after construction -- a private_initializedflag 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_configsgains 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_pipelinereadsmiddleware.guard_decorator.route_config_revisioninto a callableSecurityCheckPipelinefolds 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_configsis 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 theirRouteConfigfields asbool(...)/is not None, the same truthiness-only shapeSecurityConfig's containers have, soRouteConfig.__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 alist/dictsubclass whose mutating methods bump the counter directly, soroute_config.custom_validators.append(...)androute_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__.pysourcedSecurityDecorator,RouteConfig,BehaviorTracker, andBehaviorRulefrom the async tree (guard_core.decorators,guard_core.handlers.behavior_handler) instead of theirguard_core.sync.*equivalents. A sync adapter (Flask, Django) importingBehaviorTrackerorSecurityDecoratorfromguard_core.syncand calling an async-flavored method such asinitialize_redisorinitialize_agentgot back an un-awaited coroutine instead of a completed call, so Redis-backed behaviour tracking and decorator event dispatch silently never ran.RouteConfigandBehaviorRuleare 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 fromguard_core.sync.decoratorsandguard_core.sync.handlers.behavior_handler.SecurityConfig's extras validator required themaxminddbpackage whenevergeo_ip_handlerwas set at all, not only when the handler in play is guard-core's ownIPInfoManager. A user supplying their ownGeoIPHandlerprotocol implementation (an HTTP-backed resolver, for instance) got aValidationErrordemandingpip install guard-core[geo]for a dependency their handler never touches, contradicting the whole point of the protocol. The validator now requiresmaxminddbonly when no handler is supplied and country rules are configured, the one case where guard-core itself constructs (or expects to construct, viaipinfo_token) anIPInfoManager, and never when the caller already brought their own implementation.- The extras validator's cloud-blocking gate ignored
enable_dynamic_rules, unlikeCloudProviderCheck.applies_to/CloudIpRefreshCheck.applies_to, which both build their check whenever dynamic rules are enabled even with an emptyblock_cloud_providers. A deployment withenable_dynamic_rules=Trueand no staticblock_cloud_providerspassedSecurityConfigvalidation cleanly and then hit a rawImportErrorat 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 bothapplies_toimplementations call, so the two can no longer drift apart. CloudIpRefreshCheck.applies_to/.check()only ever consulted the globalSecurityConfig.block_cloud_providers, unlikeCloudProviderCheck, which also honours a route-levelblock_cloud_providersdecorator. A deployment that blocked cloud providers only through a route decorator built a pipeline with nocloud_ip_refreshcheck at all, socloud_handler's IP ranges for that provider were never refreshed andCloudProviderCheckmatched client IPs against stale or empty ranges.CloudIpRefreshChecknow resolves the same provider setCloudProviderCheckchecks against, throughroute_resolver.get_cloud_providers_to_check, for both itsapplies_topredicate and itscheck()body.SecurityHeadersManager.initialize_agent()had no caller anywhere in guard-core or in the shipping adapters, unlike every sibling handler'sinitialize_agent(), whichHandlerInitializer.initialize_agent_for_handlers()wires up.self.agent_handlerwas 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 wiressecurity_headers_managerthe same way asip_ban_managerandsus_patterns_handler, and both senders now build their event with theEVENT_SECURITY_HEADERS_APPLIED/EVENT_CSP_VIOLATIONconstants instead of raw string literals somuted_event_typescan name them.security_headers_appliedfires only on aTTLCache(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__inguard_core/handlers/__init__.py,guard_core/__init__.py, andguard_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, soimport guard_core.handlersfollowed by attribute access on a submodule name itself, for exampleguard_core.handlers.ipban_handlerrather than theIPBanManagerclass it defines, raisedAttributeErrorunless something else had already imported that submodule first. Each__getattr__now falls back toimportlib.util.find_spec/importlib.import_modulefor any name that resolves to a real submodule, so submodule attribute access works again underguard_core,guard_core.handlers,guard_core.sync, andguard_core.sync.handlers, and a genuinely unknown name still raisesAttributeError.find_speconly 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 asAttributeError, and the fallback runs only on actual attribute access, so a bareimport guard_corestill keepsaiohttp,maxminddb,redis,guard_agent, andcryptographyout ofsys.modules. SecurityCheckPipeline._rebuild_if_stale()read the revision and the container-size signature it stamped onto a rebuild from the liveSecurityConfig, 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 noawaitand always runs to completion within a single, uninterruptible coroutine turn, but it was a real, reproducible lost update in the generated sync tree, whoseDynamicRuleManagermutates the three watched config fields from a genuine backgroundthreading.Threadthat can interleave with an in-flight request's rebuild at the OS-thread level._rebuild_if_stale()now captures the revision, the container signature, andmuted_check_logsfromconfigbefore calling the rebuild closure, and publishes all four together with the rebuilt checks under athreading.Lockscoped 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 thetry/exceptthat wraps each check, so a raising rebuild closure -- most plausibly a check constructor failing during initialization -- propagated straight out ofexecute(), bypassing_handle_check_errorand the configuredfail_securepolicy 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 owntry/except, ahead of and separate from the one already wrapping each check. A rebuild exception is routed through the samefail_securedecision a check exception already gets: underfail_secure=True(the default) it blocks the request with the same 500Security check failedresponse a failing check produces, built through the still-valid last-known-goodself.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); underfail_secure=Falseit logs the error and continues the request against the last known-goodself.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.pytranslates the asyncself.update_task.cancel()plus itsawaitintoself.update_task.join(timeout=5), andjoinwaits for a thread rather than signalling it, so thewhile True:in_rule_update_loophad 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 callingstop()at shutdown or on worker recycle leaked one such thread per cycle, each still issuing rule fetches. Both trees now share a_stop_eventthatstop()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, soIpSecurityCheck._check_global_ip_restrictionsalways emittedreason="IP {ip} not in global allowlist/blocklist"withfilter_type="global"regardless of which check actually blocked the request. A US-based AWS IP that passed a country whitelist but was blocked byblock_cloud_providerswas reported as an allow/blocklist failure, hiding the real cause. Addedcheck_ip_access, which returns anIpAccessResult(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_allowednow delegates tocheck_ip_accessand keeps its exact signature and bool return;filter_typeandevent_typeon the emittedip_blockedevent are unchanged for backward compatibility, onlyreason(and, for cloud blocks, newcloud_provider/networkmetadata) is corrected._log_country_check_result's blocked-country branch logged at a hardcodedWARNING, bypassingconfig.log_suspicious_level. It now honorslog_suspicious_level(defaultWARNING, so existing deployments see no change), and can be silenced or re-leveled like every other block-decision log.BehaviorTracker._log_passive_mode_actionand_execute_active_mode_action(plus_execute_ban_action) loggedban/log/throttleoutcomes at a hardcodedWARNING, bypassingconfig.log_suspicious_level. They now honor it (defaultWARNING, unchanged by default). Thealertaction still always logs atCRITICAL, 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.
IpSecurityCheckis 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_coredropped from roughly 385ms to roughly 280ms on a warm filesystem (Python 3.10.19), withaiohttp,maxminddb, andredisfully absent fromsys.modulesafter the import. The remaining cost is dominated bypydantic's own plugin-entry-point discovery when the dev-onlyguard-agent/logfirestack 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 hardcodedINFOtoDEBUG. 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 structuredextra={check, path, method}metadata stays available for anyone parsing DEBUG output or theguard_core.core.checks.pipelinelogger directly.
Compatibility notes for the 3.10.0 upgrade¶
- Mutating
SecurityConfigafter the middleware has already built its pipeline now takes effect:SecurityConfigbumps a private revision counter on every attribute assignment, andSecurityCheckPipeline.execute()rebuilds its check list -- reapplyingapplies_tooverDEFAULT_CHECK_CLASSESand reassigningself.checksto a new list -- the first time it observes the revision has moved since the build it is running. A defaultSecurityConfig()with no route decorators builds["route_config", "ip_security", "rate_limit", "suspicious_activity"]; settingconfig.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 withuser_agent.SecurityCheckPipeline(checks), the constructor form every adapter anddocs/adapters/testing.mduse, still never rebuilds on its own, since it holds noconfig/rebuild_checksreference; adapters that want the rebuild behaviour go throughbuild_default_pipeline. Concurrency: the rebuild always constructs a brand-new list and swaps it intoself.checkswith a single attribute assignment, andexecute()'sforloop 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 fromconfigbefore calling the rebuild closure and publishes them together with the checks they describe, under athreading.Lockscoped 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 forSecurityConfig:SecurityCheckPipeline._rebuild_if_stale()also compares a size signature overblocked_user_agents,block_cloud_providers, andendpoint_rate_limits-- the only mutable containers anyapplies_topredicate reads -- soconfig.endpoint_rate_limits["/a"] = (1, 2)andconfig.blocked_user_agents.append("badbot")now rebuild the pipeline on the next request exactly as the equivalent whole-value assignment already did, with noDynamicRuleManagerorenable_dynamic_rules=Trueescape hatch required. That residual is now closed forRouteConfigtoo:BaseSecurityDecoratorkeeps its ownRouteConfigRevisioncounter, bumped byRouteConfig.__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_configitself 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.SecurityCheckPipelinefoldsmiddleware.guard_decorator.route_config_revisioninto 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 waySecurityConfig'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 alist/dictsubclass whose mutating methods bump the counter directly, soroute_config.custom_validators.append(...)androute_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_agentsare read bycloud_provider/cloud_ip_refresh,rate_limit, anduser_agentrespectively; each of those three already has aSecurityConfig-level orenable_dynamic_rulesescape 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 wrapsblocked_user_agentsin the same tracked-list subclass as the six route-driven list fields,geo_rate_limitsin the same tracked-dict subclass, and a new tracked-setsubclass -- coveringadd,discard,remove,pop,clear,update,intersection_update,difference_update,symmetric_difference_update, and the four in-place operators (|=,&=,-=,^=) -- coversblock_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 -- soroute_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), androute_config.blocked_user_agents.append("badbot")now rebuild the pipeline on the next request exactly as the equivalent whole-value assignment already did. EveryRouteConfigcontainer anyapplies_topredicate 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_appliedandcsp_violation.SecurityHeadersManagerwas never handed an agent handler byHandlerInitializerbefore this release, so both event senders were unreachable in every shipping adapter;initialize_agent_for_handlers()now wiressecurity_headers_managerthe same way it already wiresip_ban_managerandsus_patterns_handler. Anyone using the SaaS agent will start seeing these two event types appear.security_headers_appliedonly fires on aTTLCache(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 throughmuted_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()orHandlerInitializer.initialize_agent_integrations(), instead of atimport guard_core. This is the change that takes coldimport guard_corefrom roughly 262ms to roughly 1.6ms whenguard-agent/logfireare installed. Every construction site inside guard-core goes throughget_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'sSecurityEvent,SecurityMetric, orEventBatchmodels through a path that never touches a guard-coreSecurityConfigis 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 importingguard_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.syncre-export fix described above has no impact on the shipping adapters: flaskapi-guard and djangoapi-guard both already importSecurityDecorator,RouteConfig,BehaviorTracker, andBehaviorRulefrom the deepguard_core.sync.decorators/guard_core.sync.handlers.behavior_handlerpaths, never from the top-levelguard_core.syncfacade the fix touches, so a sync-adapter author has nothing to do here.
Docs¶
docs/architecture/telemetry.mdclaimed that shipping adapters constructSecurityCheckPipeline(checks)withoutmuted_check_logs, so pipeline-level block and error entries were not muted in practice.build_default_pipelinedoes passconfig.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-docsandmake fix-docspassed repeated-eexclusion flags topymarkdownlnt, 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_pathinpyproject.tomland 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.pygained a substitution for subdirectory-levelconftestimports so shared test fixtures mirror correctly intotests/test_sync/.- Tests that patched
ip_ban_manager/cloud_handlerat 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'sfilterwarningssilenced the 64DeprecationWarnings the suite's ownSecurityConfig(ipinfo_token=...)/SecurityConfig(ipinfo_db_path=...)construction sites raised againstwarn_deprecated_fields, plus an inertSelectableGroupsentry that matched nothing with filters disabled. Both entries are removed; the sites that only needed some geo handler to satisfyblocked_countries/whitelist_countriesvalidation now passgeo_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.pyexcludesipinfo_token/ipinfo_db_pathfrom themodel_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 underpytest.warns(DeprecationWarning, ...). The suite runs at zero warnings with nofilterwarningsconfigured at all.make vulturepassedvulture_whitelist.pyon the command line, which overrides[tool.vulture] pathsinstead of extending it, so the dead-code gate scanned only the whitelist file and neverguard_core/ortests/for the life of the repo. The target now runsuv run vulturewith no path argument, so it picks up the configuredpaths; the fuller scan reports no findings at the configuredmin_confidence.- Phase 2's two open items are settled:
scripts/unasync.py'simport aiohttp->import requestssubstitution 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 ifguard_core/handlers/cloud_handler.pyever moves its module-scopeimport aiohttpinto a function; it isn't moved in this unit becausecloud_handler.pyis only reached throughCloudProviderCheck/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"withguard-agent/logfireinstalled spends roughly 256ms of a roughly 262ms total importingguard_agent.models, and removing the eager call drops the same import to roughly 5ms. Deferring it correctly (only whenSecurityConfig(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 isSecurityConfig's own agent validator inguard_core/models.py, outside this task's declaredguard_core/__init__.pyscope, and it would also need coordinated updates totests/test_init_instrumentation.py/tests/test_sync/test_init_instrumentation.pyand the shippedguard_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 atimport guard_core. It lives in its own module,guard_core/_pydantic_plugin_mute.py(added toscripts/unasync.py'sSKIP_SRC, so it is shared between the async and sync trees exactly likemodels.py), and only runs once a configuration actually needsguard_agent:SecurityConfig.to_agent_config()calls it for theenable_agent=Truepath, andHandlerInitializer.initialize_agent_integrations()calls it for theenable_otel/enable_logfire/enable_enrichmentpath, which can wire a telemetry-capableCompositeAgentHandlerinto every consumer with no agent handler at all. The applied flag is set only once all three models are confirmed muted (orguard_agentis confirmed absent), so amodel_rebuildfailure 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_instrumentationkeeps resolving through the existing PEP 562__getattr__for back-compat. Measured on this branch (Python 3.10.19, warm filesystem,guard-agent/logfire/cryptographyinstalled):import guard_coreis roughly 1.5-2ms, down from roughly 262-287ms, andguard_agent/cryptographyare absent fromsys.modulesafter a bare import (tests/test_import_cost.pyasserts this alongside the existingaiohttp/maxminddb/redischeck). The same module owns access toSecurityEvent/SecurityMetric/EventBatchthrough one accessor,get_telemetry_model(name), which mutes and then returns the class, and every construction site inguard_core/uses it instead of importingguard_agentdirectly.tests/test_telemetry_model_access.pyandtests/conftest.pycombine 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 reachingguard_agentat edit time: it fails the build if any module outside a two-file allowlist (_pydantic_plugin_mute.pyitself, andmodels.pyforAgentConfigalone, not a telemetry model) uses a plain import, an aliased import, a submodule import followed by attribute access, or theimportlib.import_module/__import__indirection builtins to reachguard_agent, and exempts aTYPE_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, asys.meta_pathfindertests/conftest.pyinstalls for the whole test session viapytest_configure/pytest_sessionfinish, records the calling module for everyguard_agentimport the interpreter's import machinery actually resolves and fails at session end if any caller outside the same allowlist ever triggered one -- butsys.meta_pathfinders are consulted only on asys.modulescache miss, and the allowlisted mute module is normally the first thing in a session to importguard_agentlegitimately, so in practice the finder only reliably catches the first importer ofguard_agentin 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 everyguard_agentimport went through a cache miss. The third check asserts the outcome instead of the mechanism, which is the property that actually matters: apytest_sessionfinishassertion intests/conftest.pyreadsSecurityEvent/SecurityMetric/EventBatchstraight out ofsys.modules(never importing them, so the check cannot cause the very import it is testing for) and, only ifguard_agentis present insys.modulesat session end, requires all three to carryplugin_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 buildsSecurityEvent/SecurityMetric/EventBatchthrough a path that never goes through a guard-coreSecurityConfigis 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_countryand_check_blocked_countriesare unchanged (still public/tested directly); a new_resolve_country_verdicthelper backs bothcheck_ip_countryand 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_handlersgated the entire cloud and geo eager-load block behind Redis. A user withlazy_init=Falsewho awaitedguard_startup(app)(which routes throughSecurityMiddleware.initialize()->initialize_redis_handlers()) but ran WITHOUT Redis got no cloud/geo load at startup: the method returned immediately,block-until-loadedsilently did not hold, andcloud_handlerself-fetched lazily on the firstis_cloud_ipcall, racing the request. The no-Redis branch now eagerly awaits the in-memory cloud load (cloud_handler.refresh(block_cloud_providers)) whenblock_cloud_providersis 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. Thelazy_init=Trueno-Redis path is unchanged (it still warns and returns; lazy init is genuinely inert without Redis).
Behaviour changes¶
- With
lazy_init=Falseand 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 intendedblock-until-loadedsemantics 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), souvx library-skills --claudediscovers 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.pysomake check-syncparity 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 whetherlazy_initwas actually enabled. BecauseSecurityConfig.lazy_initdefaults toTrue, a user who never opted into lazy init and never uses Redis still saw the warning on every startup. The check now returns early unlesslazy_initis actuallyTrue, 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 declaretrusted_proxies, but omittedtrust_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): withproxy_headersoff the server stops forwarding the URL scheme, andhttps_enforcementonly honoursX-Forwarded-Protowhentrusted_proxiesis populated andtrust_x_forwarded_proto=True, so underenforce_https=Trueit saw plain HTTP and redirect-looped. The warning now names all three settings and calls out the redirect-loop risk. whitelist_countriesat the globalSecurityConfiglevel was exemption-only: a country in neitherwhitelist_countriesnorblocked_countrieswas allowed, and with noblocked_countriesset it was a complete no-op. This contradicted the field's documented meaning, the route-levelallow_countriesdecorator (which already restricted), and the sibling IPwhitelistfield (which already restricted). The global country check now treats a non-emptywhitelist_countriesas a true allow-list: only listed countries pass, an unresolved country is blocked (fail-closed, matchingallow_countries), and an explicit match overridesblocked_countries.
Behaviour changes¶
- The inert-lazy_init warning now fires only when
lazy_init=True; with the default orlazy_init=Falseit is silent. - Only the preempted-forwarded-header warning's message text changed;
extract_client_ipreturns the same value in every case and the warning still fires at most once per process. - A non-empty
whitelist_countriesnow restricts traffic to the listed countries. Previously it only exempted listed countries fromblocked_countriesand otherwise allowed everything, so a user who setwhitelist_countries=["US","CA"]expecting "only US/CA" got default-allow. Non-listed countries are now blocked; users who combinedwhitelist_countrieswithblocked_countriesexpecting 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-limitedWARNING(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 theIPInfoManagerinstance'sget_status()report per-subsystemready/last_refreshed/entries;IPInfoManagergainslast_refreshedandentry_count(reader.metadata().node_count) for parity withCloudManager's existinglast_updated/ip_rangesintrospection. 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.HandlerInitializernow warns whenlazy_initis configured but has no effect (Redis disabled, so its only consulted branch is unreachable) and whenSecurityConfig.geo_ip_handleris set withoutblocked_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_anomalycomparedabs(z_score)againstanomaly_threshold, so a pattern running faster than its own rolling average trippedstatistical_anomalyexactly as often as one running slower. A regex finishing early is not an anomaly; onlyz_score > anomaly_threshold(slower than average) is checked now.- Anomaly-event emission had no rate limiting:
_check_anomaliessent apattern_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 ofpattern_anomaly_statistical_anomalyevents from a single incident, which also consumed their metered event quota.PerformanceMonitornow takes a newanomaly_emission_cooldownconstructor parameter (default60.0seconds, clamped1.0-3600.0) tracked per pattern onPatternStats; 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 samePatternStatsentry thatmax_tracked_patternsalready evicts, so it cannot accumulate unbounded memory. Callbacks registered viaregister_anomaly_callbackare 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
reconregex flagged requests forrobots.txt,sitemap.xml, andsecurity.txtas reconnaissance. All three are standards-defined files meant to be fetched publicly,robots.txtis RFC 9309,sitemap.xmlis the sitemaps.org protocol and is deliberately submitted to search engines,security.txtis 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.txtfrom their own site. The three entries are removed from the alternation;readme.txt,README.md,CHANGELOG,pom.xml,build.gradle,appsettings.json, andcrossdomain.xmlremain, since those are genuine information-disclosure signals rather than standards-defined public files.
Behaviour changes¶
- Requests for
/robots.txt,/sitemap.xml, and/security.txtno longer match thereconcategory; the other entries in that pattern are unaffected. Only slower-than-average pattern executions can tripstatistical_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 peranomaly_emission_cooldownwindow (default 60s); this does not changeanomaly_callbacksbehaviour,timeout/slow_executiondetection, or theget_problematic_patterns/get_slow_patternsdiagnostics. - None from the observability additions above:
is_cloud_ip()andcheck_ip_country()return exactly what they returned before in every case (locked in by new regression tests); the new warnings andget_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-Forchain. ASGI/WSGI servers apply forwarded headers before any middleware runs, uvicorn defaults toproxy_headers=Truewithforwarded_allow_ips="127.0.0.1", and a same-host reverse proxy always connects from loopback, sorequest.client_host, whichextract_client_ipuses for the entiretrusted_proxiesdecision, 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: withtrusted_proxiesunset, documented as "no declared proxy, soX-Forwarded-Foris 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 emitsspoofing_detectedon every request. Verified end to end withtrusted_proxiesunset atrate_limit=3/60s, one caller rotatingX-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, orproxy_headers=Falseinuvicorn.run; gunicorn, hypercorn and WSGI servers have equivalent settings) and declare the proxy throughtrusted_proxies/trusted_proxy_depthso 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.mdand a cross-reference indocs/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_ipreturns exactly what it returned before in every case, the existing spoof warning andspoofing_detectedevent 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(defaultFalse). A missingRouteConfighas 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 settingrequest.state.guard_route_unresolved = True, and withroute_resolution_strict=Truethose requests are logged, emit the newroute_unresolvedevent, and are blocked with500(or logged only underpassive_mode). Seedocs/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 fromBehavioralContext, which adapters snapshot when they construct the middleware, before the application attaches itsSecurityDecorator, and rebuild only when an agent, OpenTelemetry, Logfire or enrichment is enabled. On a plain decorator-only setup the tracker resolved toNoneon every request, sousage_monitor,return_monitorandglobal_behavior_rulescounted nothing and never banned, throttled or alerted. It now falls back torequest.state.guard_decorator, the same per-request sourceRouteConfigResolveralready uses. Verified end to end:usage_monitor(max_calls=2, action="ban")previously served six requests with200, 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_strictdefaults toFalsebecause 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 returns500rather than404. Enable it where every reachable path is a known route. Adapters that never setguard_route_unresolvedare 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 assqliand 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 asquerySelector('#app')andhref='#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_rate0.857,fp_rate0.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 globalwhitelistcan receive403on 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 routeip_whitelistmatch wins over that route's ownip_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 globalblocked_countriesstill run. Only an actual routewhitelist_countriesmatch 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 ownip_whitelist. - A route-level
ip_whitelistmatch 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 routeip_whitelistmatch setrequest.state.is_whitelisted=True, exempting the request from every downstream check, so a route's own@rate_limitwas silently a no-op for its whitelisted IPs. Globalwhitelistmembership still confers full trust; a client in both the global whitelist and a route'sip_whitelistis treated as access-only on that route.
Added¶
build_default_pipeline(), one source of truth for the check pipeline. Newguard_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(default2.0s) andredis_socket_timeout(default2.0s) bound how long any Redis call can hold a request (both must be positive,0would mean a non-blocking socket, not "no timeout";Nonedisables);redis_health_check_interval(default30s,0disables) recycles stale pooled connections;redis_max_connections(defaultNone= redis-py default) caps the pool;redis_retries(default1,0disables) adds client-level retries with exponential backoff on connection/timeout errors. Note the client-level retry can re-send a non-idempotentINCRwhose 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, defaultFalse): when a Redis outage surfaces as aGuardRedisErrorinside a security check,fail_securegoverns by default (the request is blocked). SetTrueto 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",Nonesilences them) via the namedguard_corelogger instead of the root logger. Blocked-country hits still log atWARNING; no-rules / no-geolocation cases atDEBUG. Penetration-detection hits likewise honourlog_suspicious_level(previously a second hardcoded-WARNINGroot-logger path), and the remaining bare root-logger calls inutils.py/cloud_handler.pymoved onto namedguard_coreloggers. 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_lengthbefore 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_patternnow returnsbool(Trueregistered,Falserejected) instead ofNone, 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_intervalschedules 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'srefresh_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_coreimport:SecurityEvent/SecurityMetric/EventBatchsetplugin_settings={"logfire": {"record": "off"}}, so a host app runninglogfire.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: aGuardRedisErrorescaping a check is either skipped with a warning (redis_fail_open=True) or handed to the standardfail_securepath (default). Async and sync mirrors updated identically.
Fixed¶
- Built-in detection patterns rewritten to close a ReDoS. Several patterns, SQL
SELECT ... FROMandUNION 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_pipelinenow propagatesSecurityConfig.muted_check_logsto 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_safetyprobes 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_violationis now a registered, mutable event type. It can be suppressed viamuted_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_blockedevent withfilter_type="banned"; repeat requests from already-banned IPs were previously invisible. geo_ip_db_max_agenow 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_limitsattribute, 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. NewSecurityConfig.detection_scan_body(bool, defaultTrue), with a per-route override viaRouteConfig.detection_scan_bodyand thedetection_exclusion(scan_body=…)decorator argument. When set toFalse, 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 defaultTruepreserves prior behavior. Async and sync mirrors updated identically.
Changed¶
excluded_detection_body_fieldsnow 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 toapplication/x-www-form-urlencodedfield names (after decoding), and applied tomultipart/form-datatext-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
SecurityConfigat middleware startup, sodetection_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 defaultdetection_threat_score_thresholdis 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 tunedetection_threat_score_threshold, theexcluded_detection_*sets, ordetection_scan_bodyif 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, default1.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 of1.0reproduces 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 committedbaseline.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 … FROMis now scored by corroboration rather than a bare keyword, and theORDER 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_pathdeprecation warning no longer fires onNone. TheDeprecationWarningadded 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 beNone, received a spurious warning even when ipinfo was not in use. The warning now fires only when the deprecated field has a non-Nonevalue. 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-efforton_error(stage, exc, context)hook (stage∈agent_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). NewSecurityConfig.agent_strict(defaultFalse): 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_providersand@block_cloudsnow 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 realscopefield in GCP'scloud.jsonand theregionfield in AWS'sip-ranges.json(no hardcoded region lists); Azure remains provider-level. Thenetwork→regionindex is built at refresh/index time so per-requestis_cloud_ipstays O(current), a single dict lookup on a match.block_cloud_providersis now typedset[str](wasset[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 withincloud_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(default262144/ 256 KiB;ge=1024, le=10485760).detect_penetration_attemptnow skips reading and scanning the body when the request'sContent-Lengthexceeds 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, unlikedetection_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 parsedip_address()equality instead of raw string comparison, so IPv6 compact and expanded forms (::1vs0: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 theSecurityConfig.whitelist/blacklistfield descriptions. Sync mirror updated identically. X-Forwarded-Forclient-IP extraction honorstrusted_proxy_depth._extract_from_forwarded_headerpreviously returned the leftmost (client-spoofable)X-Forwarded-Forentry regardless oftrusted_proxy_depth; it now returns thetrusted_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.syncmirror had drifted from its async source (make check-syncwas failing across ~33 files). Repaired theunasyncgenerator (bareasyncio.Lockannotations,from guard_core.handlers import …package imports, theCloudIpStoreFactoryalias, the decorators logger string, and AsyncMockawait_count/await_argsassertions) and excluded the genuinely hand-maintained sync files, theRateLimitManagerthreading lock and its tests, that the regex transform cannot reproduce. A newcheck-syncpre-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 raisesAgentPackageNotInstalledError(naming the package and install command) instead of returning an ambiguousNone, so a missingguard-agentcan no longer be misreported as an "invalid config / checkagent_api_key" error by adapters. - GeoIP lookups no longer fail silently.
SecurityEventBus._lookup_countrynow logs at warning and fires theon_errorhook (stage="geoip") instead of swallowing the exception with a bareexcept Exception: return None. - Replaced the deprecated redis
setexcall withset(..., ex=ttl)in both the async and sync Redis handlers, clearing the redis-pyDeprecationWarning.
Deprecated¶
ipinfo_tokenandipinfo_db_pathnow signal deprecation at runtime. Both fields, long described as deprecated in favour of a customgeo_ip_handler, now emit aDeprecationWarningwhen explicitly set, raised once at construction from amodel_validatorkeyed onmodel_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 anyGeoIPHandlerasgeo_ip_handler.
Documentation¶
- Documented the integrator-facing Protocols. Every public
Protocolextension point,RedisHandlerProtocol,AgentHandlerProtocol,CloudIpStoreProtocol,GeoIPHandler,GuardRequest,GuardResponse/GuardResponseFactory,GuardMiddlewareProtocol(and theirSync*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 allSecurityConfigfields and the package exports, grouped by domain with a keep/deprecate/group/remove recommendation per item, theipinfo_*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_endpointnow defaults tohttps://api.guard-core.com(previouslyhttps://api.fastapi-guard.com), aligning the Guard Agent SaaS endpoint with the guard-core brand. SetSecurityConfig(agent_endpoint=...)to target a different host. - Packaging, Migrated license metadata to PEP 639:
license = "MIT"(SPDX expression) pluslicense-files = ["LICENSE"], and dropped the deprecatedLicense :: OSI Approved :: MIT Licenseclassifier. Clears the setuptoolsproject.license-table and license-classifier deprecation warnings. - Build, Removed the unused
setup.py; the release workflow now builds viapython -m build(hatchling backend) instead ofpython setup.py sdist bdist_wheel, eliminating thesetup.py install is deprecatedwarning.
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_countpreviously caughtRedisErrorand fell through to in-memory counters when EVALSHA raisedNoScriptError(afterSCRIPT FLUSH, restart, or failover to a node without our cached SHA), leaving every replica desynchronized. Now catchesNoScriptErrorspecifically inside the connection block, reloads the Lua script viascript_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" →DEBUGfor private/loopback/link-local source IPs;WARNINGfor public sources. RedisCloudIpStoredefaultkey_prefixno longer duplicates theguard:segment. Default changed from"guard:cloud_ip"to"cloud_ip"becauseRedisManager.set_keyalready prependsconfig.redis_prefix.- Cloud-provider validation derived from the
CloudProviderLiteral instead of hardcoded sets. DynamicRules.blocked_cloud_providerspayloads filter throughVALID_CLOUD_PROVIDERSwith warning on ignored entries. Sync mirror patched.@block_cloudsdecorator filters unknown cloud providers instead of silently storing them.@block_countries/@allow_countriesdecorators uppercase-normalize ISO codes to match the geo handler's output. Sync mirror updated.- Country normalization in dynamic rules.
_apply_country_rulesin async + syncDynamicRuleManageruppercases inputs and storesfrozenset[str]. - Cloud-IP store class-as-factory resolution.
HandlerInitializernow treats a bare class object passed viacloud_ip_store=RedisCloudIpStoreas a factory and invokes it withredis_handler. - Lazy-init partial-failure isolation.
_run_lazy_initwraps cloud-IP and geo-IP initialization in independenttryblocks so a cloud failure no longer disables geo init. Important now thatlazy_init=Trueis the default. - PR #19 fallout cleanup. Cleared 14 ruff F821/UP037 errors and 5 mypy errors left behind by PR #19.
SecurityConfig.dynamic_rule_intervalis now actually honored.to_agent_config()previously dropped this field on the floor; the agent's_rules_loopran on a hardcoded 300s regardless of what users configured. Fixed by forwarding the value through toAgentConfig.dynamic_rule_interval. Effective onceguard-agent >= 2.6.0is installed.
Changed¶
lazy_initdefaults toTrue. Cloud-IP refresh now runs in a background task;initialize_redis_handlersreturns immediately. Setlazy_init=Falseto preserve the old synchronous-init behavior.blocked_countriesandwhitelist_countriesare nowfrozenset[str]. Pydantic validator accepts list/tuple/set/frozenset and normalizes to uppercase.SecurityConfig.block_cloud_providersfield annotation now usesset[CloudProvider] | None(the Literal alias) instead of inlineLiteral["AWS", "GCP", "Azure"].
Added¶
cloud_ip_storeaccepts aCloudIpStoreFactorycallable (Callable[[RedisHandlerProtocol], CloudIpStoreProtocol]). Sync mirror exposesSyncCloudIpStoreFactory.CloudProviderLiteral alias andVALID_CLOUD_PROVIDERSfrozenset exported fromguard_core.models.rate_limit_script_reloadedSecurityEvent emitted on NOSCRIPT recovery.SecurityConfig.agent_status_interval, newintfield (default 300, range 60-86400) controlling how often the agent reports its status to the SaaS dashboard. Forwarded toAgentConfig.status_interval. Pairs withguard-agent >= 2.6.0which 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_securenow defaults toTrue. 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, setfail_secure=Falseexplicitly:
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 fromhttps://www.digitalocean.com/geo/google.csvand returns the set of CIDRs (IPv4 + IPv6).fetch_linode_ip_ranges(), pulls the Linode/Akamai RFC8805 CSV fromhttps://geoip.linode.com/.fetch_vultr_ip_ranges(), pulls the Vultr/Constant geofeed JSON fromhttps://geofeed.constant.com/?json.- All three providers wired into
_ALL_PROVIDERS, theCloudManagersingleton initializer, and the three provider→fetcher dispatch maps (_refresh_providers,refresh_async,_refresh_providers_via_redis_handler). Sync mirrors updated in lockstep usingrequestsinstead ofaiohttp. - Each fetcher gracefully returns an empty
set()on any HTTP / parse failure withlogging.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/encryptedwith the encrypted payload; whenNone, 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 toAgentConfig.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 toAgentConfig.guard_version. Pairs withguard-agent >= 2.4.0'sEventBatch.guard_versionfield; older agents silently drop the kwarg via Pydantic's defaultextra='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_encodingsto cover up to 7-layer polyglot encoding evasion (base64(base64(base64(base64(payload))))and similar). The loop still terminates onif content == original: break, so it stays bounded. Sync mirror updated in lockstep. - Fixed,
IPInfoManager.get_countryno longer raisesRuntimeError("Database not initialized")when the MaxMind reader is unset; it now logs a WARNING and returnsNone. Callers no longer need to wrap every geo lookup in a defensivetry/except. Sync mirror. - Fixed,
ErrorResponseFactory.apply_modifiercatches exceptions raised by the user-suppliedcustom_response_modifier, logs vialogger.exception, and returns the unmodified response. A buggy modifier can no longer crash the request pipeline. Sync mirror. - Added,
IPBanManager.banned_ipsis now an_ObservableTTLCachethat exposesevictions_counton the manager and emits a WARNING every 100 overflow evictions. Only overflow evictions are counted; TTL-expiry deletions are excluded (verified againstcachetoolssource,expire()usesCache.__delitem__, notpopitem). Sync mirror. - Added,
HandlerInitializer.initialize_dynamic_rule_manageremits a WARNING whenenable_dynamic_rules=Truebut 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_instancereference, breaking the singleton contract. When middleware or a fixture calledRedisManager(config)more than once, each successive call orphaned the previous instance, but each instance owned an independent_redisconnection set by its owninitialize(). The orphaned connection had no closer; on garbage collection it surfaced asResourceWarning: unclosed Connection(and the underlying socket / asyncio transport). Underpytest -W errorthis manifested as cascadingPytestUnraisableExceptionWarningfailures across any test suite that constructedRedisManagermore than once. __new__now follows the same true-singleton pattern asRateLimitManager: create the instance once, updateconfigon every call, return the same instance. Connections are owned by a single live instance andclose()actually closes them.- Mirror fix applied to
guard_core.sync.handlers.redis_handler.RedisManager. - No behavior change for production callers that construct
RedisManageronce 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. ProvidesCorsHandler,CorsPreflightResponse, andis_preflight.SecurityConfig.fail_securefield (defaultFalse), whenTrue, an unhandled exception in any check blocks the request instead of falling through.IPBanManager.ban_ipaccepts CIDR networks (10.0.0.0/24,2001:db8::/32) for both IPv4 and IPv6. Invalid networks raiseValueError.- Preprocessor encoding decoders: base64 (length-bounded),
\xNNhex, and\uNNNNJS unicode escapes are decoded inside the existing 3-iteration loop. - Preprocessor SQL comment stripping: case-aware in-keyword comment removal (
SELE/**/CT→SELECT,sele/**/ct→select) plus space-replacement for between-token cases (1/**/OR→1 OR). Line comments (--,#) replaced with whitespace.
Fixed¶
<?phpattack-indicator regex now matches the literal PHP open tag (was<?phpwhich made<optional and matched any string containingphp). #6- Truncated preprocessor output now interleaves attack regions and gaps in source order (was reversing gaps via
insert(0, ...)). #7 fail_secureis now actually enforceable; the previoushasattrguard always returnedFalsebecause the field was undeclared onSecurityConfig.- Compiled-regex cache key is deterministic (
{pattern}:{flags}) instead of using process-salted Pythonhash(), eliminating cross-pattern collisions. - Sync
RateLimitManagerserializes in-memory state withthreading.Lock, avoidingRuntimeError: deque mutated during iterationunder multi-threaded WSGI servers. IPBanManager.ban_iprefuses ban durations longer than the local cache TTL when Redis is unavailable; raisesValueErrorinstead of silently truncating to one hour.DynamicRuleHandler._apply_rulessnapshots config before mutating and rolls back on exception. Concurrent rule pushes serialize under a lock (asyncio.Lockasync,threading.Locksync).
Internal¶
- Test infrastructure:
tests/test_decorators/test_behavior_handler.pyandtests/test_sync/test_decorators/test_behavior_handler.pynow correctly close their Redis connections in teardown (previously leaked, surfacing asResourceWarningerrors under-W error).
v2.1.0 (2026-04-25)¶
lazy_init: background warmup instead of first-request stall¶
Changed¶
lazy_init=Truenow schedules the IPInfo MMDB download and cloud-IP provider fetches as a background task duringinitialize_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.HandlerInitializerexposes_lazy_init_task, theasyncio.Task(orthreading.Threadin the sync mirror) that runs the deferred cloud and geo bootstrap whenlazy_init=True. Failures inside the background task are caught and logged vialogging.getLogger("guard_core.core.initialization")(guard_core.sync.core.initializationfor the sync mirror) atWARNINGlevel; they never propagate.CloudIpRefreshCheck.check()no longer triggers a synchronouscloud_handler.refresh_async(...)when ranges are empty underlazy_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=Truein 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=Trueusers with strict cloud-provider blocking who can't tolerate any warmup window should stay onlazy_init=False(or continue usinglazy_init=Truewith 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/statusorSecurityMiddleware.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.
DetectionResultreplacestuple[bool, str]. Bothdetect_penetration_attempt()anddetect_penetration_patterns()now return a dataclass carryingis_threat,trigger_info,threat_categories, andthreat_scores. Callers that unpacked the tuple must migrate.- Per-category ban thresholds and durations. New
ThreatBanConfig(threshold, duration)model andSecurityConfig.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. Whencorrelate_with_detection=Trueand the IP has any positive entry insuspicious_request_counts, the rule's effective threshold is halved (floor 1). - Lazy IP lifecycle + pluggable cloud-IP store.
SecurityConfig.lazy_init=Truedefers IPInfo MMDB download and cloud-IP fetches until the first request.SecurityConfig.cloud_ip_storeaccepts aCloudIpStoreProtocol; 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_handlerandagent_handlerparameters inIPInfoManagerandCloudManagerare typed againstRedisHandlerProtocol/AgentHandlerProtocolinstead ofAny. - 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¶
DetectionResultdataclass atguard_core.detection_result(sync mirror underguard_core.sync.detection_result).ALL_DETECTION_CATEGORIES(frozenset of 16 labels) andCATEGORY_CONTEXT_MAPinguard_core.handlers.suspatterns_handler.SecurityConfigfields:excluded_detection_headers,excluded_detection_params,excluded_detection_body_fields,enabled_detection_categories(default = fullALL_DETECTION_CATEGORIESset; rejects unknown labels).RouteConfigoverride fields for the four detection-exclusion knobs (defaultNone= inherit fromSecurityConfig).ContentFilteringMixin.detection_exclusion(headers=, params=, body_fields=, categories=)decorator;Noneargs leave the correspondingRouteConfigfield 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.BehaviorRuleConfigmodel +SecurityConfig.global_behavior_rules: list[BehaviorRuleConfig]. Module-levelconfig_to_rule(cfg) -> BehaviorRulehelper.BehavioralContext.middleware: Any = Nonefield.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 optionalprocess_global_behavioral_rulescallback and runs it alongside the existing route-specific path.client_ipis 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.GeoIPHandlerprotocol gained asyncrefresh()and syncclose().IPInfoManager(token, db_path, max_age=...)with arefresh()method._max_agereplaces the hardcoded 86400 in disk-freshness checks and Redis TTL writes.CloudIpStoreProtocol(andSyncCloudIpStoreProtocolmirror) withget/set/clearmethods.InMemoryCloudIpStoreandRedisCloudIpStoredefault implementations underguard_core.handlers.cloud_ip_stores.CloudManager.set_store().refresh_async()reads from the store first, falls back to API fetch + write-back. Legacyredis_handler-only path preserved when_store is None.HandlerInitializer.initialize_redis_handlers()wirescloud_handler.set_store(config.cloud_ip_store)after Redis bootstrap when an explicit store is configured. Cloud + geo bootstrap now skipped whenlazy_init=True;CloudIpRefreshChecktriggers a one-shot init on the first request that needs cloud data.
Changed¶
detect_penetration_attempt(request, config=None, route_config=None)→DetectionResultinstead oftuple[bool, str].detect_penetration_patterns(...)→DetectionResultinstead oftuple[bool, str].GuardMiddlewareProtocol.suspicious_request_counts: dict[str, dict[str, int]](wasdict[str, int]). IP → category → count. Existing total-count semantics preserved viasum(values())everywhere they were read.SusPatternsManager.compiled_patternsand_pattern_definitionsentries are 3-tuples(regex, contexts, category)(were 2-tuples). Every regex threat dict returned by_check_regex_pattern()now carriescategory. Custom patterns are tagged"custom"and bypassenabled_categoriesfiltering.SusPatternsManager.detect()and_check_regex_patterns()accept anenabled_categories: set[str] | None = Nonefilter._check_value_enhanced()/_check_request_component()now returntuple[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) toguard:cloud_ip(JSON-encoded sorted list). See Compat notes below.
Fixed¶
setup_custom_loggingnow closes each handler before removing it, instead of relying onlogger.handlers.clear(). Closes aResourceWarningfor_io.FileIOthat surfaced underpytest -W error::ResourceWarning.- Vulture clean. Removed 10 pre-existing dead-code findings:
schemeparameter onGuardRequest.url_replace_schemeis now whitelisted (Protocol method body is...; renaming would break callers passing the kwarg by name); the fourunreachable code after raisefindings intests/test_handlers_integration.py(and sync mirrors) replaced their@asynccontextmanagermocks with class-based async/sync context managers that don't need a structurally-required deadyield. - Pydantic mypy plugin is now wired (
plugins = ["pydantic.mypy"]in[tool.mypy]). Removed 10 obsolete# type: ignoremarkers and 2 stale# TODO: Add type hints to the decoratorcomments above@field_validator/@model_validatordecorators inguard_core/models.py. Also dropped the now-unneeded[[tool.mypy.overrides]] module = "pydantic.*" follow_imports = "skip"block that was masking the plugin. unasync.pygained a multi-linefrom tests.conftest import (...)rewrite rule and a substitution rule for the newcloud_ip_store_protocolimport path. The sync mirror now correctly renamesCloudIpStoreProtocol→SyncCloudIpStoreProtocol, matching the project'sSync*-prefix convention for sync protocols.
BREAKING¶
-
detect_penetration_attempt()anddetect_penetration_patterns()returnDetectionResult. Tuple-unpacking callers must migrate: -
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()) -
SusPatternsManagercompiled-pattern tuples are 3-tuples.get_all_compiled_patterns()returnstuple[Pattern, frozenset[str], str]instead oftuple[Pattern, frozenset[str]]. Direct callers that iterate this collection must unpack three elements. -
_check_value_enhanced/_check_request_componentreturn 3-tuples. External callers (none in the framework adapters; flagged here in case downstream code reaches in). -
Cloud-IP cache namespace migration:
cloud_ranges→guard: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 namespaceguard:cloud_ip. The legacy comma-separated path is still reachable for users who explicitly set_store = Noneon theCloudManagersingleton, but the default and theRedisCloudIpStorewiring 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.0and a small migration: any adapter middleware that readsuspicious_request_counts[ip]as an int must readsum(suspicious_request_counts[ip].values())(the protocol now reflects the per-category shape). Adapters that calleddetect_penetration_attempt/detect_penetration_patternsand unpacked the 2-tuple must consumeDetectionResult.is_threat/.trigger_info. lazy_init=Falseis the default and preserves existing eager startup. Existing deployments do not need to opt in.enabled_detection_categoriesdefaults to the fullALL_DETECTION_CATEGORIESset, so detection coverage is unchanged unless the user explicitly narrows it.threat_ban_configdefaults to an empty dict and falls back to the existingauto_ban_threshold/auto_ban_durationflat 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 byscripts/unasync.py) regenerates the entireguard_core/sync/**tree plus matchingtests/test_sync/**. Hand-edits are limited to files inunasync.py:TEMPLATE_FILES(a few sync protocol files); everything else is regenerated and verified viapython scripts/unasync.py --checkin pre-commit.tests/conftest.pyredis_cleanupfixture now teardowns Redis state afteryieldin 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 configuredotel_exporter_endpointby appending/v1/tracesand/v1/metricswhen the base URL lacks the signal path. Previously, users who setotel_exporter_endpoint="http://collector:4318"received 404 Not Found from every OTLP receiver. Matches the semantics of theOTEL_EXPORTER_OTLP_ENDPOINTenvironment variable. Also correctly rewrites explicit signal suffixes (/v1/traces,/v1/metrics,/v1/logs) so the traces exporter always gets/v1/tracesand the metrics exporter always gets/v1/metricsregardless of which signal-specific path the user configured.HandlerInitializer.build_enricher()now owns aBehaviorTrackerinstance when the user'sSecurityDecoratordoes not supply one, and caches it asHandlerInitializer.behavior_trackerfor reuse. Without this fix,guard.behavior.correlation_keyandguard.behavior.recent_event_countnever populated for adapters that instantiate the middleware and decorator separately (all four current adapters).BehavioralContextgained an optionalbehavior_trackerfield andBehavioralProcessornow threads writes throughcontext.behavior_trackerwhen present, falling back toguard_decorator.behavior_trackerotherwise. This closes the architectural gap where the enricher read from one tracker while writes went to another,guard.behavior.recent_event_countnow populates end-to-end when adapters thread theHandlerInitializer.behavior_trackerthrough theirBehavioralContextconstruction (shipping in the next adapter releases).
Compat notes¶
- No public API changes.
OtelHandler._otlp_signal_endpointis an internal helper.BehavioralContext.behavior_trackerhas a default ofNoneso existing callers continue to work unchanged. - Adapters should bump their
guard-core>=1.2.1pin to pick up all three fixes. See the matchingfastapi-guard 5.1.1,flaskapi-guard,djapi-guard,tornadoapi-guardreleases, 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. Newguard_core.core.events.enricher.EventEnricher+EnrichmentContextrun insideCompositeAgentHandler.send_event/.send_metricbetween the mute filter and fan-out. Four independent strategies, each fails soft, a faulty strategy never blocks emission. Async + sync mirror parity maintained viascripts/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 newBehaviorTracker.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_eventandLogfireHandler.send_eventnow walkevent.metadataand attach everyguard.*key (excepttraceparent/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+EnrichmentContextdataclass (sync mirror underguard_core/sync/).guard_core.core.events.enricher.ThreatScorer.score_for(event_type)+ deterministic_THREAT_SCORE_MAP.- Eight
ENRICHMENT_KEY_*constants inguard_core.core.events.event_types(async + sync). SecurityConfig.enable_enrichment: boolfield with avalidate_agent_configmodel validator that raisesValidationErrorwhen enrichment is requested withoutenable_agent=True.HandlerInitializer.build_enricher()factory.build_composite_handler()now passes the enricher intoCompositeAgentHandler;shutdown_agent_integrations()clears the enricher reference. The early-exit guard ininitialize_agent_integrationsnow accounts forenable_enrichment.CompositeAgentHandler(..., enricher=...)parameter;send_event/send_metricinvoke the enricher between the mute filter and handler fan-out.DynamicRuleManager.match_event(event) -> tuple[str, int] | Nonereturning(rule_id, version)when the cached rule matches.BehaviorTracker.get_recent_event_count(ip, window_seconds) -> intaggregating usage counts across all endpoints for the given IP.OtelHandler.send_event+LogfireHandler.send_eventforwardguard.*metadata keys as span attributes.
Docs¶
docs/architecture/telemetry.mdupdated with: the two-tier model table, the newenable_enrichmentconfig 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=Trueand/orenable_logfire=Truecontinue 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). Includesotel_service_name,otel_exporter_endpoint, andotel_resource_attributesfor deployment/env/version tagging. Requires theguard-core[otel]extra. - Logfire export, opt-in via
enable_logfire=True. Events aslogfire.span("guard.event.<type>", ...), metrics as structuredlogfire.infocalls. Requires theguard-core[logfire]extra. - W3C trace-context propagation, incoming
traceparentandtracestateheaders 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, andmuted_check_logsonSecurityConfig. Applied insideCompositeAgentHandlerso every exporter (guard-agent, OTel, Logfire) sees the same mute rules.muted_check_logsalso suppresses in-checklog_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 constructingSecurityEventBus/MetricsCollectordirectly. See Adapter upgrade notes below. - Validated mute values,
muted_event_types,muted_metric_types, andmuted_check_logsall validate at config time againstEVENT_TYPE_VALUES/METRIC_TYPE_VALUES/CHECK_NAME_VALUES. Typos raiseValidationErrorwith the full set of valid values in the message. - Idempotent handler lifecycle,
OtelHandler/LogfireHandlerstart()/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(validatedset[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 oneAgentHandlerProtocol, appliesEventFilterat fan-out.guard_core.core.events.event_types.EventFilter+EVENT_TYPE_VALUES/METRIC_TYPE_VALUES/CHECK_NAME_VALUESfrozensets (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-awarelog_activitywrapper that honoursmuted_check_logs.docs/architecture/telemetry.md, full field reference, troubleshooting, and adapter wiring guidance.[otel]and[logfire]optional extras inpyproject.toml.
Fixed¶
logfire.metric(...)never existed, replaced withlogfire.info("guard.metric.<type>", ...)for structured metric logs.send_metricnow warns (once per unknown type) instead of silently dropping when handed a metric_type outsideMETRIC_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.mdcovering the two-tier model (raw OTel/Logfire signal; guard-agent as a parallel enriched exporter), mute field reference with all valid values, incomingtraceparent/tracestatebehaviour, 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) acrossdocs/index.md,docs/llms.txt,docs/architecture/telemetry.md.
v1.0.3 (2026-04-05)¶
Added¶
- Guard processing time instrumentation on all request-scoped
SecurityEventobjects viaget_pipeline_response_time(). Covers events fromSecurityEventBus,SecurityCheckPipeline,RateLimitManager,BaseSecurityDecorator, andsend_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 withX-Forwarded-Forheaders as a spoofing attempt whentrusted_proxieswas not configured (the default) - Added IP caching in
extract_client_ipto avoid redundant lookups across the request lifecycle
Added¶
- Guard processing time instrumentation on all request-scoped
SecurityEventobjects viaget_pipeline_response_time(). Covers events fromSecurityEventBus,SecurityCheckPipeline,RateLimitManager,BaseSecurityDecorator, andsend_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 viascripts/unasync.py, including sync versions of all 17 security checks, handlers, decorators, protocols, detection engine, and utilities scripts/unasync.pytransformation tool converting async code to sync (async deftodef,awaitremoved,aiohttptorequests,redis.asynciotoredis,asyncio.Locktothreading.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.mdwith project documentation, badges, and ecosystem overview.safety-project.inifor dependency vulnerability scanningMANIFEST.inand.gitattributesfor packaging.python-versionspecifying 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.txtfor 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
CloudManagerwith IP range change logging and improved provider refresh logic - Updated
SusPatternsManagerwith additional detection logic - Enhanced
BehavioralProcessor,ErrorResponseFactory, andRouteConfigResolverinternals - Minor updates to
IPInfoManagerhandler - Updated
BaseSecurityDecoratorroute 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
GuardRequestandGuardResponseprotocols 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.