Release Notes¶
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.