v3.12.0
Exclusion-path bypass closed, excluded paths now enforce bans and rate limits, identity-block escalation no longer bypassed by a stale whitelist flag, bounded body reads are timeout-bounded, and Azure range fetching is hardened (v3.12.0)
Special thanks to @arpitjain099 for reporting GHSA-c2r5-9jw9-m8q5 and https://github.com/rennf93/fastapi-guard/security/advisories/GHSA-xv6g-49vj-7w9c ! Thank you for your reports, your clarity and cooperation. And for making fastapi-guard and guard-core more secure!
Added
- guard-core now reports its own version automatically.
guard_core.__version__is resolved once at import from installed package metadata (falling back to"unknown"when metadata is unavailable, such as a source checkout), and is forwarded to the agent asguard_core_versionwith no operator action required. The pre-existingagent_guard_versionfield is unchanged and still carries the framework wrapper version, which is operator-supplied; because adapters declareguard-corewithout a version constraint, the wrapper version cannot identify which guard-core is actually installed, andguard_core_versioncan. BoundedBodyReaderandSyncBoundedBodyReader(guard_core/protocols/request_protocol.py/guard_core/sync/protocols/request_protocol.py): a new, optional capability protocol,async def read_body_prefix(self, max_bytes: int) -> bytes, that an adapter implements alongsideGuardRequestto let detection inspect a size-capped prefix of a request body that has no usableContent-Length(for example chunked transfer-encoding), without reading or buffering the rest. It is exported fromguard_core.protocols.__all__/guard_core.sync.protocols.__all__and reachable asguard_core.BoundedBodyReader/guard_core.sync.SyncBoundedBodyReaderfrom the start.BoundedResponseBodyReader(guard_core/protocols/response_protocol.py) and its blocking mirrorSyncBoundedResponseBodyReader(guard_core/sync/protocols/response_protocol.py): the response-side counterpart ofBoundedBodyReader,async def read_body_prefix(self, max_bytes: int) -> bytes, that an adapter implements alongsideGuardResponseto letreturn_patternbehaviour rules inspect a size-capped prefix of a response body without buffering the rest and without disrupting delivery of the full body to the client. Exported fromguard_core.protocols.__all__/guard_core.sync.protocols.__all__and reachable asguard_core.BoundedResponseBodyReader/guard_core.sync.SyncBoundedResponseBodyReader. Two newSecurityConfigfields control it:behavior_scan_response_body: bool(defaultFalse) gates response-body reading forreturn_patternrules entirely, andbehavior_max_response_body_inspect_bytes: int(default262144, range 1024-10485760) bounds how many bytes guard-core reads and retains per response. Because the response body is application-produced rather than attacker-supplied -- an attacker who finds any large streaming endpoint (a file download, an export, an SSE stream) controls which endpoint they hit, not what it produces -- this cap bounds what guard-core itself retains, not what the endpoint produces; it is not a full-body scan guarantee, and adapter implementations ofread_body_prefixmust keep a streaming response streaming to the client after inspection (buffer up to the cap, then replay it plus the untouched, unbuffered remainder of the original stream) rather than reading the whole body before forwarding any of it, or every large download becomes a memory spike and every SSE/long-poll connection breaks. Both obligations are spelled out in the protocol docstring with the same force asBoundedBodyReader's GHSA-xv6g-49vj-7w9c memory-obligation language, since guard-core has no way to enforce either from the caller side.SecurityConfig.body_read_timeout: float(default3.0seconds, range0.0-30.0exclusive of zero): the wall-clock boundasyncio.wait_forapplies, in the ASYNC guard_core tree only, to every adapter call guard-core makes throughBoundedBodyReader.read_body_prefix,BoundedResponseBodyReader.read_body_prefix, and the plainGuardRequest.bodyread. The SYNC tree (guard_core.sync) calls the adapter's read directly and does not use this value at all. See the body-read timeout entry below.- Constructing a
SecurityConfigwith aglobal_behavior_rulesreturn_patternentry whose pattern is notstatus:-prefixed whilebehavior_scan_response_bodyisFalsenow raisesValueErrornaming the offending pattern instead of silently accepting a rule that could never match (validate_global_return_pattern_body_scan).@security.return_monitor()and@security.behavior_analysis()(guard_core/decorators/behavioral.py) reject the identical combination at decoration time for per-route rules, via the same_validate_return_pattern_body_scanhelper.
Fixed
-
Setting
SecurityConfig.geo_ip_handleremitted aUserWarningwhenever neitherblocked_countriesnorwhitelist_countrieswas configured, but that condition is not decidable at theSecurityConfiglayer:geo_ip_handlerhas legitimate consumers the global config cannot see.RateLimitCheck._check_geo_rate_limitreads it forRouteConfig.geo_rate_limitswithout ever consulting a country list;check_country_accessconsumes it through route-levelblocked_countries/whitelist_countriesset by@access_controldecorators, whichmodels.pycannot import; andDynamicRuleManagerpopulates the country lists after startup, so the handler is legitimately set before them. Because__setattr__re-validates on every individual field assignment, the warning also fired on the intermediate state of a valid sequential build-up (config.geo_ip_handler = hfollowed byconfig.blocked_countries = {...}). The warning is removed. The two warnings whose conditionsSecurityConfiggenuinely can decide, theexclude_pathsroot-entry warning and the country-allowlist-shadows-blocklist warning, are unchanged. -
RequestValidator.is_path_excludedmatchedexclude_pathswith a plainstr.startswithand no normalisation or path-boundary check, andBypassHandler.handle_passthroughreturnedcall_next(request)the moment it matched, before the client IP was even extracted. Because/staticships in the defaultexclude_paths, any request whose path merely began with an excluded entry skipped the entire security pipeline: detection, rate limiting, IP banning, user-agent filtering and emergency mode./staticadmin,/redoc-admin/delete-all,/static../.aws/credentialsand/static/../../../root/.ssh/id_rsawere all treated as excluded, and a banned IP still reached them. Present in every released version. Path matching now lives in a pureguard_core.core.validation.path_matchingmodule that percent-decodes recursively (bounded, failing closed if still encoded), folds backslashes, collapses dot segments, and then requires an exact match or a true/-bounded prefix; anything that cannot be confidently normalised is never excluded, so it receives the full pipeline. -
Because the new path-matching module above normalises before comparing, a degenerate
exclude_pathsentry such as'','.','..','//','\\','%2f'or'%2e%2e'normalises to/, which matches every path and would disable the entire security pipeline application-wide -- the same blast radius as the bug above, reachable this time by a configuration mistake (a trailing comma in an env-var-driven list produces'') rather than a crafted request. A shared_validate_exclude_paths_valuehelper rejects such entries with aValueErrornaming the offending value, and is invoked from every placeexclude_pathscan be set: thefield_validatorat construction,SecurityConfig.__setattr__for a direct runtime assignment toexclude_paths, and an overriddenmodel_copywhen itsupdatetouchesexclude_paths. A literal'/'is still accepted, since an operator may mean it, but warns loudly (attributed to the caller's line in all three paths) about what it disables.SecurityConfigdeliberately leavesvalidate_assignmentunset: turning it on for the whole model would also re-run every other field's validator, and the country-shadow model validator, on every assignment, unaudited side effects this fix does not need. -
The
path_excludedevent was emitted on every request to an excluded path, uncached and unsampled. Orchestrator liveness probes hitting/healthz,/health,/metrics,/readyand/liveon a timer therefore generated one telemetry event per probe, indefinitely. Emission is now throttled through aTTLCache(maxsize=1000, ttl=300)keyed on the normalised path, mirroring the existing throttle onsecurity_headers_applied, so a repeatedly-probed path emits at most one event per five minutes. The throttle gates only the event: the exclusion decision itself is computed before and independently of any cache lookup and can never be influenced by cache state in either direction. -
RequestValidator._current_normalized_exclude_pathscaches the normalised exclude list keyed on the exclude list's own content (tuple(config.exclude_paths), compared by value against the tuple captured at the last recompute), so any mutation that changes whatexclude_pathsactually contains -- a whole-value reassignment or a size-preserving in-place edit such asconfig.exclude_paths[1] = "/other"-- is picked up on the very next request regardless of whether anything else onconfigchanged. -
escalate_suspicious_if_threatis renamed toescalate_identity_violation(guard_core/core/checks/helpers.py). Separately,IpSecurityCheck.check()computedrequest.state.is_whitelisted-- the flagescalate_identity_violationand several other checks (UserAgentCheck,CloudProviderCheck,SuspiciousActivityCheck,RateLimitCheck) read to skip their own logic for a request the global whitelist already vouches for -- by precomputing it via the new_resolve_global_ip_accessbefore calling_check_route_ip_restrictions, and leaving it set while that route-level check ran. A route that blocks an IP through its ownroute_config.ip_blacklistis a decision_resolve_global_ip_accessnever evaluated, but with the flag already sitting onrequest.statefrom the global check moments earlier,escalate_identity_violation's ownis_whitelistedguard saw it asTrueand returned immediately: a route-level block for an IP that also happens to sit on the globalconfig.whitelistwas silently never escalated, regardless of the payload --suspicious_request_countsstayed empty,ban_ipwas never called, and noEVENT_PENETRATION_ATTEMPTwas ever emitted for it, even for a real SQLi payload.IpSecurityCheck.check()no longer writesrequest.state.is_whitelistedbefore the route-level check; it is set (unconditionally, whether the request is allowed or blocked) only inside_check_global_ip_restrictions, the one place that actually performs the global whitelist evaluation the flag is meant to describe. A route-level block therefore always reachesescalate_identity_violationwith the flag at its unset (falsy) default, and only a genuine global-whitelist allow -- or a route that defers to the global check because it has noip_whitelist/ip_blacklistof its own -- sets itTrue. Tests now cover the same IP appearing in bothconfig.whitelistandroute_config.ip_blacklist(escalates normally) and in bothconfig.whitelistandroute_config.ip_whitelist(request is allowed, as before); the previously-existing tests used disjoint IPs for the global and route lists, which is why this was missed. -
escalate_identity_violationincrementssuspicious_request_countsand checksthreat_ban_configusing the real detectedthreat_categoriesfromget_cached_detection_result(falling back to["uncategorized"]when detection ran but returned none), not a synthetic label describing why the request was identity-blocked; that synthetic label (ip_restriction,ip_blocked, oruser_agent) is still attached to the emittedpenetration_attemptevent asviolation_categoryfor observability, but does not feed the ban counters. -
SuspiciousActivityCheckno longer mutatesmiddleware.suspicious_request_countsthrough its own private method (_increment_per_category), which had no lock, no_MAX_TRACKED_SUSPICIOUS_IPScap, and no LRU touch-on-access; it now calls the same shared_increment_suspicious_countshelperescalate_identity_violationuses, so there is exactly one writer tosuspicious_request_countsin each of the async and sync trees, and the lock/cap/LRU protections below cover the primary detection path, not only the identity-block escalation path._increment_suspicious_countsnow caps the tracker at_MAX_TRACKED_SUSPICIOUS_IPS(10,000) entries and touches (moves to most-recently-used) the tracked IP on every increment, evicting the coldest, longest-untouched entry first on overflow, so an attacker rotating through a large IP pool cannot push their own actively-tracked entry out of the map to reset their ban tally. The critical section (plain dict manipulation, noawaitinside it) is guarded by athreading.Lockin both the async and sync trees -- loop-agnostic, since it never needs to be released across anawait-- so_increment_suspicious_countsis a plaindefin the async tree too, notasync def. -
get_cached_detection_resultcaches the per-requestDetectionResultonrequest.state, keyed on the identity of both therequestand theroute_configobject it was computed for (cached[0] is request and cached[1] is route_config), sodetect_penetration_attemptruns at most once per request regardless of how many checks (escalate_identity_violation,SuspiciousActivityCheck) need the result, and a cache entry can never be served to an unrelated request or route. -
escalate_identity_violation's exception handler wraps everylogger.exception(...)call (both the top-level failure log and theip_ban_failedevent-bus report) in its owntry/except, so a failure in the logging sink itself (a broken handler, a full disk) cannot escape the handler and turn the caller's already-decided block response into an unhandled exception. -
fetch_azure_ip_rangesin the prior release had no elapsed-budget concept at all: the page-scrape request used a fixed 10-second timeout, and the JSON download used a fixed 30-second timeout retried up to 3 times with a flat 2-second sleep between attempts, so the theoretical worst case for the whole call was around 104 seconds (10 + 3x30 + 2x2).fetch_azure_ip_rangesnow computes a single deadline (_AZURE_DOWNLOAD_MAX_ELAPSED_SECONDS, 20 seconds) at the start of the call and sizes both the page-fetch timeout and every download-attempt timeout from the time remaining against it, so the worst-case wall time for the whole call is bounded by that single 20-second budget instead of the sum of several independently-fixed timeouts. -
Azure download-URL discovery now tries three extractors in priority order -- an
id="failoverLink"anchor, anhrefmatch, and a plain-textServiceTags_Public_*.jsonURL match -- and validates every candidate URL against the same allowlist before using it: the scheme must behttpsand the parsed hostname must equaldownload.microsoft.comexactly, so a lookalike host such asdownload.microsoft.com.evil.comis rejected rather than matched as a prefix. The prior release had only the latter two extractors and validated neither, so a compromised or MITM'd page could point the download at an attacker-controlled host or an internal address and have the response fetched and trusted as Azure CIDR data. The download request itself (_download_azure_service_tags) now also passesallow_redirects=False(the prior release's inline download usedaiohttp's, and in the sync treerequests's, default of following redirects), and any 3xx response now raises aValueErrorbeforeraise_for_status()or JSON parsing runs -- a passed-allowlist URL could otherwise still answer with a redirect to a completely different origin and have that response trusted, defeating the host allowlist regardless of how strict it is. A URL that genuinely redirects now fails the same way every other Azure fetch error already does: logged, an empty range set for that refresh, no false blocking of legitimate Azure IPs. When more than one datedServiceTags_Public_*.jsonURL is present on the page, selection now parses the 8-digit date in the filename as a real calendar date (rejecting one that fails to parse or is in the future) and orders candidates deterministically (has a valid date, then the date itself, then the URL string), instead of picking whichever candidate the discovery regex happened to match first; a warning is logged after the winner is chosen when it has no parseable date at all, or when its date is more than 90 days old, so a silently-stale fallback pages an operator instead of running unnoticed. The retry loop that wraps this download also narrows what it retries: the prior release's singleexcept Exceptionspanned the request, the status check, and the JSON parse, so a connection error, a bad HTTP status, and a malformed JSON body were all retried identically up to three times._download_azure_service_tagsnow retries onlysession.getraising (a connection-level failure); a response that comes back with a bad status (raise_for_status()) or a body that fails to parse as JSON (response.json()) is outside the retriedtry/exceptand fails the attempt immediately, since retrying the same URL cannot change either outcome. A failed fetch still resolves to an empty range set either way, so this does not change false-blocking risk, only how many attempts (and how much added latency) a non-transient failure costs;test_fetch_azure_ip_ranges_download_failureis renamed totest_fetch_azure_ip_ranges_bad_status_is_not_retriedto match, alongside a newtest_fetch_azure_ip_ranges_bad_json_body_is_not_retried. -
The country-shadow warning (
warn_country_allowlist_shadows_blocklist, added in the prior release) is amodel_validator(mode="after"), which -- sinceSecurityConfigdoes not setvalidate_assignment-- only ever ran at construction; a runtime assignment such asconfig.blocked_countries = ["CN"]afterwhitelist_countrieswas already set never re-checked the shadow condition at all.SecurityConfig.__setattr__now re-runs the same check when eitherblocked_countriesorwhitelist_countriesis assigned directly, deduplicated against the value already stored for that field so a periodic dynamic-rule re-sync that reassigns the same countries on every poll interval does not re-warn on every cycle; both sides of the dedup comparison are normalised tofrozenset[str]first, since country fields are stored asfrozenset[str]and comparing the raw incoming value directly against the stored frozenset would re-warn every time the same countries were reassigned as alist/tuple/setrather than afrozenset. The truthiness check (self.whitelist_countries and self.blocked_countries) runs before the field is mutated andrevisionis bumped, so a value whose__bool__raises aborts the assignment with no observable state change instead of leaving the object partially written. -
SecurityConfig.model_copy(update={...})bypassed the same country-shadow check the entry above closes for direct assignment:base.model_copy(update={"blocked_countries": [...]})on abasewith a non-emptywhitelist_countriesproduced a copy with both lists populated and no warning, sincemodel_copyneither runswarn_country_allowlist_shadows_blocklist(a construction-only model validator) nor goes through__setattr__.model_copynow re-runs the same check via_warn_country_allowlist_shadows_blocklistwhenever itsupdatetoucheswhitelist_countriesorblocked_countries, evaluated against the copy's final state, mirroring theexclude_pathshandling the override already had for its own field. Unlike the__setattr__path, this one is not deduplicated against the base's prior value: amodel_copycall is a one-shot snapshot, not a stream of reassignments to compare against itself, and a fresh copy with both fields populated is shadowed regardless of what the base looked like.deep=, otherupdatekeys, subclass identity, and the plain no-updatecall are unaffected. -
On a request with no usable
Content-Length(for exampleTransfer-Encoding: chunked), the prior release's fail-closedContent-Lengthgate (_body_exceeds_inspection_cap) always skipped body inspection outright, since there was nothing to size the body against. This release replaces that gate with a bounded-read system:_parse_content_lengthparses a presentContent-Length(tolerating surrounding whitespace, rejecting a leading+, thousands separators, hex notation, and non-ASCII digit forms as malformed) to decide whether the body is small enough to read in full, and falls back to_read_capped_body_prefix-- reading only up todetection_max_body_inspect_bytesthrough the newBoundedBodyReadercapability -- whenContent-Lengthis absent, so a chunked request can now be inspected up to the cap instead of being skipped entirely. Both branches (Content-Lengthpresent or absent) now share onerequest.statecache, keyed on request identity the same wayget_cached_detection_resultis, so two independent readers on the same request in the same pipeline run (for example@guard.honeypot_detection([...])'s validator andSuspiciousActivityCheck's body scan) both see the same prefix -- and paybody_read_timeoutat most once between them -- instead of the second one draining an already-consumed single-use stream, scoring no threat, or paying a second full timeout on a stalled read. When an adapter'sread_body_prefix/bodyreturns something other thanbytes, the cache logs a warning naming the request type, the accessor, and the offending returned type before falling back to the same fail-closed, treated-as-unscannable outcome a raising reader already produces. Only a successful read is ever cached; a failure (the adapter raises, the read times out, or it returns something other thanbytes) is never written torequest.state, so the next reader on the same request always gets its own fresh attempt instead of being served a stale failure a retry would have turned into a real, scannable body -- a transientConnectionResetErroron the first consumer no longer permanently disables body inspection for every later consumer of the same request. A genuinely empty body (b"") is a successful read like any other and is cached and shared exactly the same way. -
Body-read timeout (async tree only). Nothing bounded the wait on an adapter's
read_body_prefix/bodycall: a stalled SSE producer, a long-poll that never yields, or a buggy adapter implementation left_safe_readawaiting forever, pinning the request (and, at volume, every worker handling one) indefinitely -- a real, unbounded DoS with no recovery short of a process restart._safe_read(guard_core/utils.py) now wraps the call inasyncio.wait_for(reader(), timeout=timeout); on timeout it degrades to the identical fail-closed, could-not-evaluate outcome already used when the reader raises, through whichever caller's existing throttled logging already covers that path (BehaviorTracker._log_body_unavailable'sTTLCacheon the response side; the request side already returnsNonesilently for a raising reader and continues to). This bound is configurable viaSecurityConfig.body_read_timeout(default 3.0 seconds) and applies uniformly toBoundedBodyReader.read_body_prefix,BoundedResponseBodyReader.read_body_prefix, and the plainGuardRequest.bodyread, since all three route through_safe_read-- in the ASYNC guard_core tree only. The SYNC tree (guard_core.sync) bounds the same wait with a semaphore plus a joined daemon thread. A blocking adapter call can't be cancelled from the outside, so_safe_readinguard_core/sync/utils.pyhands each read attempt to its owndaemon=Truethread and joins it for up to the remainingtimeoutbudget; thread growth is capped by athreading.Semaphore(sync_body_read_max_concurrent)(default 64) that the caller must acquire, also bounded bytimeout, before the thread is even started.SyncGuardRequest.body,SyncBoundedBodyReader.read_body_prefix, andSyncBoundedResponseBodyReader.read_body_prefixall route through it, and bothSecurityConfig.body_read_timeoutandSecurityConfig.sync_body_read_max_concurrentapply to the sync tree exactly as they do the async one. If the semaphore can't be acquired in time, guard-core logs the concurrency limit being reached and treats the body as unavailable for detection, the same fail-closed outcome used when the join itself times out; a timed-out thread is left to keep running in the background, unjoined, until the adapter's own call returns. -
IpSecurityCheck.check()now also runs_check_global_ip_restrictions(withroute_config=None, so no per-route override participates) for a request markedguard_exclusion_scopedbyBypassHandler.handle_passthrough, after the dynamicip_ban_managercheck. Previously an excluded path reached only the banned-IP check;config.blacklist,config.whitelist,config.blocked_countries, and cloud-provider blocking were not enforced there, so a statically blacklisted IP got a 403 on a normal path but was served on an excluded one._check_global_ip_restrictionsgained anescalatekeyword, and the exclusion-scoped call passesescalate=False, so a block on an excluded path never runs penetration detection to categorise it forthreat_ban_config/auto_ban_threshold; route-level IP/country restrictions (_check_route_ip_restrictions) remain skipped on an excluded path, matching every other route-decorator-driven check. Which checks still run at all on an exclusion-scoped request is now aSecurityCheck.enforced_on_excluded_paths: ClassVar[bool] = Falseclass attribute thatSecurityCheckPipeline.executereads directly off each check instance (TrueonRouteConfigCheck,IpSecurityCheck, andRateLimitCheck, the same three checks enforced there before this change), rather than a name list living apart from the check classes it would otherwise have to name; a test asserts the set of checks derived fromDEFAULT_CHECK_CLASSESwith the attribute set matches exactly those three. -
guard_core.models.SecurityConfig.dynamic_rule_intervalhad no floor, while theAgentConfigfield it is forwarded to (guard_agent.models.AgentConfig.dynamic_rule_interval) enforcesge=60. Setting it below 60 did not raise by default, sinceSecurityConfigandAgentConfigare validated independently and agent construction only raises on a rejected value whenagent_strict=True; otherwise a too-low value silently disabled the entire agent integration instead of erroring.dynamic_rule_intervalnow also enforcesge=60, matching the floorAgentConfigalready requires. Every otherSecurityConfigfield forwarded toAgentConfiginto_agent_config()was checked against the installedAgentConfig's ownFieldconstraints for the same class of mismatch:agent_status_intervalalready carriesge=60, le=86400, at least as strict asAgentConfig.status_interval'sge=60;agent_buffer_size,agent_flush_interval,agent_max_concurrent_flushes,agent_timeout,agent_retry_attempts,agent_backoff_factor,agent_max_payload_size,agent_compression_threshold, andagent_high_watermark_ratiocarry no bound on either side, so there is nothing for either side to drift from.dynamic_rule_intervalwas the only mismatch found. -
The
file_inclusionprotocol-relative-URL pattern inguard_core/handlers/suspatterns_handler.pymatched any//hostsubstring, with no check for what preceded the//. Every ordinary absolute URL (https://example.com,http://api.example.com) contains//immediately after its scheme's colon, so any request body, header, or param carrying a webhook URL, a profile link, or a URL mentioned in prose was flagged as a file-inclusion attack. Present in every released version. The pattern now carries a(?<!:)negative lookbehind on the//, so a//preceded by:(i.e. part of a normalscheme://URL) no longer matches; a scheme-less protocol-relative reference such as//evil.com/shell.txtor?file=//evil.com/x.txt, which is the actual RFI shape this pattern exists to catch, is unaffected, since nothing precedes its//. Dangerous URL schemes (php://,data://,zip://,phar://, etc.) are matched by a separate pattern immediately above this one and were never affected. -
Fixing the pattern above removed an accidental side effect it had been relied on for: two SSRF seed payloads in the attack-simulation benchmark (
http://169.254.169.254/latest/meta-data/, the AWS/GCP/Azure cloud-metadata endpoint, andhttp://localhost:8080/admin) were detected only because the over-broad file-inclusion pattern happened to match their//, not because the dedicatedssrfpattern actually matched them — it never did. Thatssrfpattern's private/link-local branch appended exactly one more\d+octet after a two- or three-octet prefix and then required a\s|$|/boundary immediately after it, which no real four-octet IPv4 address (or ahost:portsuch aslocalhost:8080) can satisfy; every "detection" credited to it for a real dotted-quad private IP or alocalhost/loopback address with a port was actually coming from the unrelated file-inclusion pattern. The pattern now matches a full remaining octet run per prefix (169\.254(?:\.\d{1,3}){2},192\.168(?:\.\d{1,3}){2},10(?:\.\d{1,3}){3},172\.(?:1[6-9]|2[0-9]|3[01])(?:\.\d{1,3}){2}) and tolerates an optional:portbefore the boundary, sohttp://169.254.169.254/latest/meta-data/,http://localhost:8080/admin, and real10.x.x.x/172.16-31.x.x/192.168.x.xtargets are now matched by thessrfcategory itself. The attack-simulation benchmark'sdetection_rateis unchanged (0.8568), now for the correct reason. -
The
cmd_injectionshell-substitution pattern's separator character class was[;&|`], so a markdown code-span backtick wrapping a$()/${}reference (backtick,$(id), backtick) was matched as a shell separator immediately followed by a command/variable substitution, flagging ordinary documentation and support-ticket text such as "see the backtick-wrapped$(id)example" or "use backtick-wrapped${HOME}in paths" as command injection. The class is now[;&|]; a real separator-prefixed substitution (; $(id),| $(whoami),& ${HOME}, with or without a space) still matches. A bare backtick-wrapped substitution with no leading separator is no longer detected by this pattern, which is not a regression: backtick and$()/${}are alternative, non-nesting shell substitution syntaxes, so wrapping one in the other is not a realistic attack shape, and a bare backtick-wrapped command with no$()/${}inside it was never matched by this pattern either way. -
BehaviorTracker._check_response_patternguarded response-body access withhasattr(response, "body").hasattrswallows exceptions, and a framework adapter'sGuardResponse.bodyis a property that raisesAttributeErrorfor a response whose body is not fully materialized (a streaming response in particular), so a body that could not be read was indistinguishable from a body that was absent. Everyjson:,regex:, and bare-substringreturn_patternrule evaluated against such a response therefore silently returnedFalse, a clean "no match" the code never actually computed, with only a throttled log line as a hint that something was being skipped.status:patterns, which readresponse.status_codeand never touch the body, were never affected._check_response_patternno longer touches.bodyorhasattrfor the body-reading formats at all: it now requires the response to implement theBoundedResponseBodyReadercapability, detected with an explicitisinstancecheck rather than a property probe (safe against the same raising-property problem, since anisinstancecheck against aruntime_checkableProtocolnever invokes a method member, only a property member), gated by the opt-inSecurityConfig.behavior_scan_response_bodyflag (defaultFalse, so upgrading changes nothing until it is turned on) and bounded bySecurityConfig.body_read_timeout(see above). When the flag is off, the capability is absent,read_body_prefixraises or times out, or it returns something other thanbytes,_check_response_patternreturnsNone: a could-not-evaluate outcome distinct fromFalse, logged through the same throttledTTLCache(maxsize=1000, ttl=300)this warning already used (keyed by pattern, at most once per five minutes per distinct pattern).track_return_patternfoldsNoneinto "no occurrence recorded", the same asFalse, so a rule that cannot be evaluated still never reports a match it did not observe -- it just also never records a false one.BehaviorTrackerdoes not cache the response-body prefix it reads. An earlier iteration cached it in aweakref.WeakKeyDictionarykeyed on the response object itself, on the theory that multiplereturn_patternrules checked against the same response in one pipeline run should share a single read; that cache is removed, because measured production demand for it was exactly onereturn_patternrule, and it broke correctness for every deployment paying its cost: a response type using__slots__without__weakref__(a realistic shape for a lightweight adapter wrapper) raisedTypeErroron theweakref.ref()the dict requires, which the caller's outerexcept Exceptionswallowed into a silent, permanent, process-lifetimeFalse("no match") for everyreturn_patternrule evaluated against that adapter's responses, logged only via an untetheredlogger.warning/logger.erroron every single call rather than the same throttledTTLCachethis code already uses elsewhere; and a response wrapper object whose identity is pooled/reused across logically distinct responses (kept alive specifically so it can be reused, which is exactly what defeats a weak reference ever expiring the stale entry) served the first response's cached body prefix to a pattern check running against a completely different, later response. Eachreturn_patternrule checked against a response now performs its own independent, bounded read through_safe_read; a deployment with several such rules configured against the same response pays that read once per rule instead of once per response, the accepted tradeoff for removing a cache that was actively wrong rather than merely redundant. -
SecurityConfig.global_behavior_rules.append(...)(or.extend/.insert/slice-assignment) bypassed bothvalidate_global_return_pattern_body_scanand the decoration-time check in@security.return_monitor()/@security.behavior_analysis()(see Added, above): areturn_patternrule with a body-reading pattern could be added to an existingSecurityConfigat runtime whilebehavior_scan_response_bodywasFalseand would silently never fire -- the exact rule shape construction already rejects, entering through a door neither validator was wired to.global_behavior_rulesis nowtuple[BehaviorRuleConfig, ...]instead oflist[BehaviorRuleConfig](see Behaviour changes, below), so.append/.extend/.insert/slice-assignment all raiseAttributeError/TypeErrorimmediately: the in-place-mutation path is closed outright rather than validated call by call. The two paths that remain -- whole-field reassignment (config.global_behavior_rules = (...)) andmodel_copy(update={"global_behavior_rules": (...)})-- now re-run the same check construction uses, throughSecurityConfig.__setattr__and themodel_copyoverride respectively, the same mechanismexclude_pathsand the country fields already use in both methods.behavior_scan_response_bodyis covered symmetrically: reassigning it toFalsewhileglobal_behavior_rulesalready holds a body-readingreturn_patternrule is rejected the same way, since disabling the flag out from under an existing rule reaches the identical silently-dead-rule outcome from the other direction, andmodel_copy(update={"behavior_scan_response_body": ...})re-validates the copy's existing rules against the new flag value too. This does not cover mutating an individualBehaviorRuleConfigalready inside the tuple in place (config.global_behavior_rules[0].pattern = "..."):BehaviorRuleConfigremains an ordinary, non-frozen Pydantic model, and nothing in the engine mutates one in place today, but closing that residual gap would needmodel_config = ConfigDict(frozen=True)onBehaviorRuleConfigand is left for a follow-up. Auditing every otherSecurityConfiglist/dict/set field for the same construction-vs-mutation gap found nine more with a real instance of it --whitelist,blacklist,trusted_proxies(IP/CIDR formatfield_validators that raise),threat_ban_config(category-membershipfield_validator, raises),muted_event_types,muted_metric_types,muted_check_logs,enabled_detection_categories(membershipfield_validators, raise), andblock_cloud_providers(silently filters rather than raising) -- all nine of which are closed in this same release too (see the next two entries).whitelist_countries/blocked_countries(frozenset, already immutable, so immune to in-place mutation) andexclude_paths/global_behavior_ruleswere, at that point, the only fields free of this class of bug. Also found, and also closed in this release:validate_geo_ip_handler_exists(thegeo_ip_handler-requirement check, distinct from the shadow-blocklist check this release closes formodel_copy) had the same__setattr__-reassignment gap the shadow check had before the prior release --config.blocked_countries = [...]on a config with nogeo_ip_handlerand noipinfo_tokenset the field with no error, and the resulting country check was then silently never consulted at request time. -
The nine fields named above are now closed the same way
global_behavior_ruleswas: an immutable type plus the identical__setattr__/model_copyre-validation wiring.whitelist(tuple[str, ...] | None, keeping itsNone"no whitelist" sentinel),blacklist, andtrusted_proxies(bothtuple[str, ...]) replacelist[str], so.append()/.extend()/.insert()/slice-assignment now raiseAttributeError/TypeErrorinstead of mutating an unvalidated list.enabled_detection_categories,muted_event_types,muted_metric_types, andmuted_check_logsreplaceset[str]withfrozenset[str], so.add()/.discard()/.update()raise the same way.threat_ban_configreplacesdict[str, ThreatBanConfig]withtypes.MappingProxyType[str, ThreatBanConfig]-- the standard library's read-only mapping view, since Python has no built-in frozen dict -- soconfig.threat_ban_config["xss"] = ThreatBanConfig(...)now raisesTypeError("'mappingproxy' object does not support item assignment") instead of silently bypassing the category-membership checkvalidate_threat_ban_configalready enforced at construction.block_cloud_providersreplacesset[str] | Nonewithfrozenset[str] | None, closing the same in-place-mutation gap, andvalidate_cloud_providersnow raisesValueErrornaming any entry whose provider name (the part before an optional:!regionsuffix) is notAWS/GCP/Azure, instead of silently dropping it -- this field was already unsafe at construction, not only on later mutation:SecurityConfig(block_cloud_providers={"AWS", "GPC"})previously leftGPCtraffic completely unblocked with no error, warning, or log line anywhere. Every one of the nine fields'field_validators moved tomode="before"so the identical coerce-and-validate function backs both the constructor path (via Pydantic) and the new assignment/model_copypath (called directly), the same shared-function shape_validate_exclude_paths_valuealready established:config.whitelist = ["not-an-ip"]andbase.model_copy(update={"threat_ban_config": {"bogus": ThreatBanConfig(threshold=1, duration=1)}})both now raise the identicalValueErrorconstruction would, and a rejected reassignment leaves the field andrevisionunchanged, the same no-partial-state guaranteeexclude_paths/global_behavior_rulesalready provide. -
validate_geo_ip_handler_existsis closed the same way the country-shadow check was closed in the prior release:SecurityConfig.__setattr__andmodel_copynow re-run it wheneverblocked_countries,whitelist_countries,geo_ip_handler, oripinfo_tokenchanges after construction, evaluating the merged final state the same way construction does.config.blocked_countries = ["US"]on a config with nogeo_ip_handlerand noipinfo_tokennow raisesValueError("geo_ip_handler is required if blocked_countries or whitelist_countries is set") immediately instead of setting the field and leaving the country check silently unconsulted at request time; the field andrevisionare left unchanged on the raise. The construction-time convenience of auto-building anIPInfoManagerfrom a setipinfo_tokenis preserved on the assignment path too: reassigningblocked_countries/whitelist_countrieson a config that already carriesipinfo_token(and nogeo_ip_handler) auto-constructs the handler the same way construction does, and assigningconfig.geo_ip_handler = Nonewhile country rules are still active with no token set is rejected the same way the missing-handler case at construction is.model_copy(update={...})re-runs the same check against the copy's fully merged state, so a single call that sets both keys together,base.model_copy(update={"blocked_countries": ["US"], "geo_ip_handler": handler}), still succeeds. -
guard-core's
ssrfcategory matched a target address only when it was written as a literal dotted-quad,localhost, or a small fixed set of hostnames, so an alternate encoding of an address already on that blocklist was not recognised at all:http://2130706433/,http://0177.0.0.1/, andhttp://0x7f.1/all resolve to127.0.0.1, which the literal pattern blocks, yet none of them matched anything. A new_decode_legacy_ipv4_host(guard_core/handlers/suspatterns_handler.py) implements the BSDinet_atonone-to-four-part decimal/octal/hex grammar and range-checks the decoded 32-bit address against the same private/link-local networks the dotted-quad pattern already recognises (0.0.0.0/8,127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16), plus Alibaba Cloud's100.100.100.200/32. Cloud metadata coverage had been AWS-only (169.254.169.254); GCP'smetadata.google.internalandmetadata.goog, and the same Alibaba address, are now matched by the hostname pattern alongside it. A single bare decimal integer under_MIN_BARE_DECIMAL_LEGACY_IPV4(1 << 24, 16,777,216) is excluded from the decoded-address check, since every integer in that range decodes into0.0.0.0/8and the entire TCP port range would otherwise be reported as an SSRF target --redis://6379,grpc://50051, andamqp://5672are connection strings, not addresses, and are not flagged. The one value excluded from that exclusion is the canonical decimal form of0.0.0.0itself:http://0/andhttp://0:8080/still resolve to loopback and are still detected, rather than being swept into the same port-number carve-out as the connection strings around it. -
ContentPreprocessor._decode_base64_candidates(guard_core/detection_engine/preprocessor.py) decoded any 20-plus character run of the base64 alphabet witherrors="ignore", which discarded exactly the invalid bytes that would have revealed a decode as garbage rather than genuine content; an ordinary REST path or identifier can easily be 20-plus base64-alphabet characters, so this silently corrupted content before any pattern ever saw it, measured at 55 of 69 realistic strings, including S3 object keys, git SHAs, session tokens, content-hash filenames, and GCP'scomputeMetadata/v1/instance/path, which decoded to mojibake. A corrupted string cannot match a signature, so this was a detection hole, not a cosmetic one. Decoding now requires a strict UTF-8 decode (which raises, rather than silently discarding bytes, on invalid input) and a minimum printable-ASCII ratio (_PRINTABLE_ASCII_RATIO_THRESHOLD, settled at0.5after measurement showed the strict UTF-8 decode alone did nearly all the work), and a token that fails either check is left exactly as it was rather than replaced with the garbage decode; corruption on the same 69-string sample drops to 3, all JWTs, which decode to their own JSON claims, so content injected into a claim stays scannable, and recall on a 47-payload base64-wrapped attack corpus is unchanged. A genuinely undecodable byte is no longer an automatic reject: a lossy decode (errors="replace") is attempted, and accepted or rejected by how much of the result it corrupts (_MAX_REPLACEMENT_CHAR_RATIO = 0.2, a share of the decoded text) rather than a fixed count of replacement characters, so a short payload carrying one bad byte and a long payload carrying several are judged by the same standard rather than the long payload being rejected outright once it crosses a fixed count; this closes a gate an attacker could otherwise use deliberately, padding a payload with enough invalid UTF-8 bytes to guarantee it stays hidden as opaque base64, regardless of how much of the surrounding content was perfectly valid. Hex literals (0x7f...) are exempted from base64 decoding by their0xprefix specifically; a token that merely looks like hex (an even-length run of0-9a-fwith no0xprefix) is not exempted, since base64's own alphabet overlaps the hex-safe character set closely enough that an attacker could otherwise choose plaintext whose encoding happens to land in it and skip decoding entirely. -
Every regex pattern in
SusPatternsManageris compiled withre.IGNORECASE | re.MULTILINE(guard_core/handlers/suspatterns_handler.py, both thePatternCompiler-backed path and the legacy no-compiler path), so a pattern anchored with a bare^/$does not anchor to the start/end of the whole scanned content, it anchors to the start/end of any line within it. 25 patterns acrossrecon,cms_probing,sqli,cmd_injection, andssrfused a bare^/$this way, so a benign multi-line document containing one line that happened to match a pattern's keyword tripped it regardless of the rest of the document. Verified against the pre-fix code: an internal routes list containing a line reading exactly/versionwas reported asrecon; a workspace role list containing a line reading exactlyadministratorwas reported ascms_probing; a migration note ending a line inORDER BY 2was reported assqli; a shell-usage example ending a line inecho 'debug' #was also reported assqli; and a deployment-script excerpt containing the linesh -x deploy.shwas reported ascmd_injection. Anchoring is now decided per pattern instead of applied uniformly: a pattern whose content is genuinely expected to be a path (sensitive_file, and most ofrecon/cms_probing's single-token patterns) is anchored to the whole string (\A...\Z); a pattern that needs to fire on a probe appearing inside a larger document instead requires an explicitscheme://host/prefix (cms_probing'swp-admin/administrator/xmlrpcpattern, so a URL mentioned in prose is still caught while a bare mention of "backing up the .htaccess file" is not) or a narrower context-sensitive alternative (sqli'sORDER BY/comment patterns now also match immediately after a=,?, or&even mid-body;cmd_injectiongained a pattern for a shell invocation with the-cflag preceded by a newline). This preserves the embedded-attack detection a uniform whole-string anchor would otherwise have removed for content that is, in practice, never nothing but the attack payload -- verified for command injection, SQL injection, and CMS probing embedded inside a larger request body. The twossrfpatterns that also use bare^/$are left as they are: their adjacent\salternative already consumes a newline as a boundary regardless of theMULTILINEflag, so anchoring them would have been a no-op. -
Separately, the
reconcategory'smanagement/system/version/config_dump/credentialsprobe pattern matched only a single top-level path segment (/management), so the same probe nested under an application prefix (/app/management/health,/v2/system/version) went undetected. The pattern now matches the same keywords at any depth under a genuine absolute path, while still requiring the path to actually start with/, so a home-directory reference such as~/.aws/credentials(a credentials-exposure concern, not a reconnaissance probe) is not swept into the wider match. -
guard-core's
ldapcategory had no pattern for a filter-injection payload that opens with a wildcard rather than a leading(&/(|conjunction:cn=*)(uid=*and*)(password=*), both classic LDAP authentication-bypass shapes, went undetected. A new pattern requires the literal*)(to be immediately followed by an attribute name and=(\*\)\(\s*[a-zA-Z][\w-]*\s*=), which matches both payloads while leaving ordinary prose that merely contains the same three characters in sequence -- an arithmetic aside (total = a*)(b+c)) or a footnote marker (See appendix A*)(footnote 3) for details) -- unmatched, since neither is followed by an identifier and=.
Behaviour changes
SecurityConfig.global_behavior_rulesis nowtuple[BehaviorRuleConfig, ...]instead oflist[BehaviorRuleConfig](see Fixed, above). Code that previously called.append()/.extend()/.insert()on it, or assigned to a slice, now gets an immediateAttributeError/TypeErrorinstead of a silently-unvalidated mutation; replace an in-place.append()with a whole-field reassignment,config.global_behavior_rules = (*config.global_behavior_rules, new_rule), which is validated the same way construction is.- Breaking: nine more
SecurityConfigfields change type (see Fixed, above).whitelist: tuple[str, ...] | None,blacklist: tuple[str, ...],trusted_proxies: tuple[str, ...](werelist[str]);enabled_detection_categories,muted_event_types,muted_metric_types,muted_check_logs: frozenset[str](wereset[str]);threat_ban_config: types.MappingProxyType[str, ThreatBanConfig](wasdict[str, ThreatBanConfig]);block_cloud_providers: frozenset[str] | None(wasset[str] | None). Code that mutates one of these in place --config.whitelist.append(...),config.muted_event_types.add(...),config.threat_ban_config["xss"] = ...-- now raisesAttributeError/TypeErrorinstead of silently mutating an unvalidated collection. Migration: reassign the whole field instead, which is validated the same way construction is --config.whitelist = [*config.whitelist, "10.0.0.5"],config.muted_event_types = config.muted_event_types | {"dynamic_rule_violation"},config.threat_ban_config = {**config.threat_ban_config, "xss": ThreatBanConfig(threshold=3, duration=3600)}. A plainlist/set/dictis still accepted on reassignment (and at construction) and coerced to the immutable type; only in-place mutation of the field's current value is closed.block_cloud_providersadditionally changes behavior independent of its type: an unrecognized provider name that was previously dropped silently now raisesValueError, at both construction and reassignment; a deployment relying on the old silent-drop to tolerate a stale or misspelled provider name must fix the name. config.blocked_countries/config.whitelist_countriescan no longer be reassigned (or set viamodel_copy) into a state with no way to resolve a country, i.e. nogeo_ip_handlerand noipinfo_token(see Fixed, above). A deployment that reassigns these fields at runtime (for example fromDynamicRuleManager) without ageo_ip_handleralready configured will now get aValueErrorwhere it previously got silence and an inert country check; configuregeo_ip_handler(or the deprecatedipinfo_token) up front, even before any country rule is set, to keep a runtime-only country-rule flow working. This mirrors the identical construction-time requirementvalidate_geo_ip_handler_existsalready enforced.- Identity-block escalation (route/global IP restrictions,
ip_blocked, and blocked user-agents) no longer contributes toauto_ban_threshold/threat_ban_configon its own; it only does so when the same request is also flagged by penetration detection, and the categories it counts towardthreat_ban_configare the real detected threat categories, not a label describing the identity-block reason. Deployments that relied on repeated identity-only blocks (e.g. a whole blocked country) eventually producing a ban should configure that country/IP range directly inblocked_countries/blacklistinstead, since a ban is no longer a side effect of being blocked often. request.state.is_whitelistednow reflects only the outcome of the most recently evaluated global whitelist check for the request: it is left at its unset (falsy) default until_check_global_ip_restrictionsactually runs, and is never set from a route-levelip_blacklist/ip_whitelistdecision, which does not evaluate the global whitelist at all. Any direct consumer ofrequest.state.is_whitelisted(as opposed to the checks that already read it throughgetattr(..., False)) should use the same default-Falseaccess pattern._increment_suspicious_countsis a plaindefguarded by athreading.Lockin both the async and sync trees (notasync def); any direct caller must call it synchronously, withoutawait.exclude_pathsno longer bypasses the security pipeline entirely.BypassHandler.handle_passthroughnow marks a matched requestguard_exclusion_scopedonrequest.stateand returnsNoneinstead of callingcall_nextdirectly, so the request still reachesSecurityCheckPipeline.execute. There, only theroute_config,ip_security, andrate_limitchecks run for an exclusion-scoped request (SecurityCheck.enforced_on_excluded_paths, see above); every other check, includingsuspicious_activity(payload detection), is skipped, so an excluded path is still cheap. Concretely: an already-banned IP is still blocked byIpSecurityCheck._check_banned_ipon an excluded path, a statically blacklisted or whitelisted IP, a blocked country, or a blocked cloud provider is likewise still enforced byIpSecurityCheck._check_global_ip_restrictions(see above), and rate limiting is still enforced, but detection itself does not run against an excluded path, and blocking on it never triggersescalate_identity_violation's detection-based categorisation either.BehavioralProcessortreats an exclusion-scoped request as having no behavior tracker, so it is never sampled for usage/frequency/return-pattern rules and cannot trip a behavioral auto-ban (guard_core/handlers/behavior_handler.py'sban_ip(..., "behavioral_violation")), no matter how many times the excluded path is hit. This closes the gap where a health-check endpoint's fixed one-IP, fixed-interval traffic pattern was exactly what afrequencybehavioral rule looks for: previously, an excluded liveness probe could earn itself a behavioral ban and start failing its own orchestrator's health check. Passive mode is unaffected (it never blocks, excluded path or not); non-excluded requests are unaffected, since the new gate only activates whenguard_exclusion_scopedis set. Applications that relied onexclude_pathsmaking a path invisible to a standing IP ban, a static blacklist/whitelist/country/cloud restriction, or rate limiting should reconsider that path's inclusion inexclude_pathsnow that all of them are enforced there.- Response-body-reading
return_patternrules (json:,regex:, bare-substring) are opt-in:behavior_scan_response_bodydefaults toFalse, so upgrading to this release reads no additional bytes and matches no additional patterns until it is turned on. A deployment that already configured such a rule inglobal_behavior_ruleswill now fail to construct itsSecurityConfiguntil it either setsbehavior_scan_response_body=Trueor replaces the rule with astatus:pattern; the same shape via@security.return_monitor()/@security.behavior_analysis()now raises the identicalValueErrorat decoration time. The removedhasattr(response, "body")codepath (see above) never matched anything for a genuinely streaming response, whose.bodyraises when read before the stream is drained -- for that case, no previously-working behaviour is lost by any of this. It is not true for an ordinary, non-streaming response, whose.bodyis a plain, non-raising, already-materialized attribute: that shape matched correctly under the removedhasattr/.bodycodepath, and does not match under this release -- opt-in flag on or not -- until the adapter also implements the newBoundedResponseBodyReader.read_body_prefixcapability. This is a lockstep-upgrade requirement across the ecosystem. guard-core, fastapi-guard, flaskapi-guard, and djapi-guard are separate repositories; every adapter pinsguard-corewith no version constraint (see the automatic-version-reporting entry above, added for exactly this reason). Upgrading guard-core alone, without also upgrading the adapter to a release that implementsBoundedResponseBodyReader, silently drops everyreturn_patternbody rule for that adapter -- even withbehavior_scan_response_body=Trueexplicitly set -- with no error and no signal beyond the pre-existing throttled could-not-evaluate log line.status:patterns, which read onlyresponse.status_codeand never touch the body, are unaffected in every case, streaming or not, upgraded adapter or not. - In the ASYNC guard_core tree, any adapter call guard-core makes to read a request or response body --
BoundedBodyReader.read_body_prefix,BoundedResponseBodyReader.read_body_prefix, or the plainGuardRequest.body-- that previously could hang indefinitely on a stalled adapter now fails closed (treated the same as a raising reader) afterSecurityConfig.body_read_timeout(default 3.0 seconds). The SYNC tree does not:SyncGuardRequest.body,SyncBoundedBodyReader.read_body_prefix, andSyncBoundedResponseBodyReader.read_body_prefixblock the calling thread for as long as the adapter takes andbody_read_timeouthas no effect there; bound a stalled sync adapter read with the WSGI server's own request timeout instead (gunicorn--timeout, uWSGIharakiri).
Documentation
detection_max_body_inspect_bytes's field description, theBoundedBodyReader/SyncBoundedBodyReaderprotocol docstrings, anddocs/api/protocols.md/docs/configuration/detection-tuning.mdstate plainly that bounded body inspection only ever scans the leadingdetection_max_body_inspect_bytesbytes of the body: a payload padded past that offset, or a signature split across the boundary, is not detected. This is an inherent tradeoff of bounded-memory scanning, not a defect, and no wording implying parity with full-body scanning is used.- The
BoundedBodyReader/SyncBoundedBodyReaderdocstrings spell out that the memory bound is adapter-cooperative only: guard-core'sprefix[:max_bytes]slice trims whatread_body_prefixalready returned, but cannot stop an implementation from buffering more thanmax_bytesinternally before returning it. Implementations must not buffer more thanmax_byteswhile producing the prefix; guard-core has no way to enforce this from the caller side. See GHSA-xv6g-49vj-7w9c. docs/api/protocols.md,docs/configuration/security-config.md,docs/api/behavior-rules.md,docs/api/models.md, anddocs/internals/behavioral.mddocument theBoundedResponseBodyReader/SyncBoundedResponseBodyReaderprotocol, thebehavior_scan_response_body/behavior_max_response_body_inspect_bytes/body_read_timeoutfields, the could-not-evaluate outcome and its throttled logging, and the streaming/DoS reasoning behind the cap (guard-core bounds what it retains, not what the endpoint produces; a streaming response must stay streaming to the client after inspection).docs/api/protocols.md'sGuardResponse.bodyrow, which stated it was "used by behavioral return pattern matching", is corrected: it no longer is, for exactly the reason described above.docs/configuration/security-config.mdanddocs/configuration/detection-tuning.mdnow state plainly thatbody_read_timeoutbounds the async guard_core tree only, and that a sync deployment must bound a stalled adapter read with its own WSGI server's request timeout instead -- no wording in either page implies guard-core bounds a sync adapter read. Both this file anddocs/release-notes.mdcall out, in bold, that upgrading guard-core alone does not restore a previously-workingreturn_patternbody rule for a non-streaming response until the adapter also shipsBoundedResponseBodyReadersupport, naming fastapi-guard, flaskapi-guard, and djapi-guard explicitly as the lockstep-upgrade requirement this is.docs/api/ban-config.mddocuments a known limitation innormalize_url_path: a path segment of..;(a traversal segment carrying a servlet-style matrix parameter) is not recognised as..and is kept literal, so/static/..;/etc/passwdnormalises to itself andpath_is_excludedreports it as excluded when/staticis configured inexclude_paths. This is deliberately not fixed: Flask, Starlette/FastAPI, Django, and nginx all route on;literally and do not strip it, so the path guard-core evaluates already matches what every framework guard-core ships an adapter for actually resolves; the gap is only reachable behind a Java-servlet-style component that strips;paramsbefore final routing, which no supported adapter introduces. Stripping;...from every segment to close it would have broken legitimate matrix-parameter paths (/orders;customer=42/items, valid under RFC 3986), whichnormalize_url_pathpreserves literally today and is now covered by tests documenting both the limitation and that legitimate matrix parameters survive normalisation unmodified.docs/internals/api-surface-audit.mdcarried a per-fieldLinecolumn againstguard_core/models.pythat had gone stale (drifted out of sync with the field it named) more than once as fields were inserted above it in prior updates to this same audit. The column is removed; the table is keyed on field name only, which does not drift, with agrep -none-liner given for anyone who wants a field's current line. While re-verifying the table against source, two fields present inSecurityConfigbut missing from the table (detection_anomaly_emission_cooldown,detection_min_samples_for_anomaly) are now itemized; the table lists all 115 fields the totals line already claimed, and thedetectiondomain subtotal is corrected from 16 to 18 to match.docs/api/models.md,docs/api/behavior-rules.md,docs/configuration/security-config.md, anddocs/internals/api-surface-audit.mdare corrected:global_behavior_rulesand the nine fields above no longer show their pre-3.12.0list/set/dicttypes,block_cloud_providers's validator entry no longer says it "silently filters", andvalidate_geo_ip_handler_exists's entry now notes it also runs on reassignment andmodel_copy.SecurityConfig.body_read_timeout's field description said the SYNC tree "calls the adapter's read directly and does not use this value at all" because "a blocking call cannot be cancelled from the outside without the thread-pool machinery guard-core removed". Both claims are now stale: the sync tree bounds a read by running it on its own daemon thread and joining that thread withbody_read_timeout(budgeted by the newsync_body_read_max_concurrent), so the field is honoured in both trees, just through a join-timeout rather than a true cancellation (the thread itself keeps running until the adapter's call returns; only the caller stops waiting for it). The field description,docs/api/models.md,docs/configuration/security-config.md, anddocs/configuration/detection-tuning.mdare corrected to say so.docs/internals/detection-engine.mdsaidextract_attack_regions()scans for "21 attack indicator patterns"; four more were added toContentPreprocessor.attack_indicatorsalongside the fixes above (the shell metacharacters`,\$\(, and[;&|], so truncation pastmax_content_lengthno longer drops the characters acmd_injectionsignature needs, plus a bare dotted-quad indicator so an IPv4 address inside a truncated attack region is preserved), and the count is corrected to 25.
What's Changed
- chore(deps): bump github/codeql-action from 4.37.4 to 4.37.6 by @dependabot[bot] in #65
- release(v3.12.0): restore non-functional detection paths, bound body reads, harden config by @rennf93 in #64
Full Changelog: 3.11.0...3.12.0