Skip to content

v3.12.0

Choose a tag to compare

@rennf93 rennf93 released this 14 Aug 00:44
· 15 commits to master since this release

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

Fixed

  • Setting SecurityConfig.geo_ip_handler emitted a UserWarning whenever neither blocked_countries nor whitelist_countries was configured, but that condition is not decidable at the SecurityConfig layer: geo_ip_handler has legitimate consumers the global config cannot see. RateLimitCheck._check_geo_rate_limit reads it for RouteConfig.geo_rate_limits without ever consulting a country list; check_country_access consumes it through route-level blocked_countries/whitelist_countries set by @access_control decorators, which models.py cannot import; and DynamicRuleManager populates 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 = h followed by config.blocked_countries = {...}). The warning is removed. The two warnings whose conditions SecurityConfig genuinely can decide, the exclude_paths root-entry warning and the country-allowlist-shadows-blocklist warning, are unchanged.

  • RequestValidator.is_path_excluded matched exclude_paths with a plain str.startswith and no normalisation or path-boundary check, and BypassHandler.handle_passthrough returned call_next(request) the moment it matched, before the client IP was even extracted. Because /static ships in the default exclude_paths, any request whose path merely began with an excluded entry skipped the entire security pipeline: detection, rate limiting, IP banning, user-agent filtering and emergency mode. /staticadmin, /redoc-admin/delete-all, /static../.aws/credentials and /static/../../../root/.ssh/id_rsa were all treated as excluded, and a banned IP still reached them. Present in every released version. Path matching now lives in a pure guard_core.core.validation.path_matching module that percent-decodes recursively (bounded, failing closed if still encoded), folds backslashes, collapses dot segments, and then requires an exact match or a true /-bounded prefix; anything that cannot be confidently normalised is never excluded, so it receives the full pipeline.

  • Because the new path-matching module above normalises before comparing, a degenerate exclude_paths entry such as '', '.', '..', '//', '\\', '%2f' or '%2e%2e' normalises to /, which matches every path and would disable the entire security pipeline application-wide -- the same blast radius as the bug above, reachable this time by a configuration mistake (a trailing comma in an env-var-driven list produces '') rather than a crafted request. A shared _validate_exclude_paths_value helper rejects such entries with a ValueError naming the offending value, and is invoked from every place exclude_paths can be set: the field_validator at construction, SecurityConfig.__setattr__ for a direct runtime assignment to exclude_paths, and an overridden model_copy when its update touches exclude_paths. A literal '/' is still accepted, since an operator may mean it, but warns loudly (attributed to the caller's line in all three paths) about what it disables. SecurityConfig deliberately leaves validate_assignment unset: turning it on for the whole model would also re-run every other field's validator, and the country-shadow model validator, on every assignment, unaudited side effects this fix does not need.

  • The path_excluded event was emitted on every request to an excluded path, uncached and unsampled. Orchestrator liveness probes hitting /healthz, /health, /metrics, /ready and /live on a timer therefore generated one telemetry event per probe, indefinitely. Emission is now throttled through a TTLCache(maxsize=1000, ttl=300) keyed on the normalised path, mirroring the existing throttle on security_headers_applied, so a repeatedly-probed path emits at most one event per five minutes. The throttle gates only the event: the exclusion decision itself is computed before and independently of any cache lookup and can never be influenced by cache state in either direction.

  • RequestValidator._current_normalized_exclude_paths caches the normalised exclude list keyed on the exclude list's own content (tuple(config.exclude_paths), compared by value against the tuple captured at the last recompute), so any mutation that changes what exclude_paths actually contains -- a whole-value reassignment or a size-preserving in-place edit such as config.exclude_paths[1] = "/other" -- is picked up on the very next request regardless of whether anything else on config changed.

  • escalate_suspicious_if_threat is renamed to escalate_identity_violation (guard_core/core/checks/helpers.py). Separately, IpSecurityCheck.check() computed request.state.is_whitelisted -- the flag escalate_identity_violation and several other checks (UserAgentCheck, CloudProviderCheck, SuspiciousActivityCheck, RateLimitCheck) read to skip their own logic for a request the global whitelist already vouches for -- by precomputing it via the new _resolve_global_ip_access before calling _check_route_ip_restrictions, and leaving it set while that route-level check ran. A route that blocks an IP through its own route_config.ip_blacklist is a decision _resolve_global_ip_access never evaluated, but with the flag already sitting on request.state from the global check moments earlier, escalate_identity_violation's own is_whitelisted guard saw it as True and returned immediately: a route-level block for an IP that also happens to sit on the global config.whitelist was silently never escalated, regardless of the payload -- suspicious_request_counts stayed empty, ban_ip was never called, and no EVENT_PENETRATION_ATTEMPT was ever emitted for it, even for a real SQLi payload. IpSecurityCheck.check() no longer writes request.state.is_whitelisted before the route-level check; it is set (unconditionally, whether the request is allowed or blocked) only inside _check_global_ip_restrictions, the one place that actually performs the global whitelist evaluation the flag is meant to describe. A route-level block therefore always reaches escalate_identity_violation with the flag at its unset (falsy) default, and only a genuine global-whitelist allow -- or a route that defers to the global check because it has no ip_whitelist/ip_blacklist of its own -- sets it True. Tests now cover the same IP appearing in both config.whitelist and route_config.ip_blacklist (escalates normally) and in both config.whitelist and route_config.ip_whitelist (request is allowed, as before); the previously-existing tests used disjoint IPs for the global and route lists, which is why this was missed.

  • escalate_identity_violation increments suspicious_request_counts and checks threat_ban_config using the real detected threat_categories from get_cached_detection_result (falling back to ["uncategorized"] when detection ran but returned none), not a synthetic label describing why the request was identity-blocked; that synthetic label (ip_restriction, ip_blocked, or user_agent) is still attached to the emitted penetration_attempt event as violation_category for observability, but does not feed the ban counters.

  • SuspiciousActivityCheck no longer mutates middleware.suspicious_request_counts through its own private method (_increment_per_category), which had no lock, no _MAX_TRACKED_SUSPICIOUS_IPS cap, and no LRU touch-on-access; it now calls the same shared _increment_suspicious_counts helper escalate_identity_violation uses, so there is exactly one writer to suspicious_request_counts in each of the async and sync trees, and the lock/cap/LRU protections below cover the primary detection path, not only the identity-block escalation path. _increment_suspicious_counts now caps the tracker at _MAX_TRACKED_SUSPICIOUS_IPS (10,000) entries and touches (moves to most-recently-used) the tracked IP on every increment, evicting the coldest, longest-untouched entry first on overflow, so an attacker rotating through a large IP pool cannot push their own actively-tracked entry out of the map to reset their ban tally. The critical section (plain dict manipulation, no await inside it) is guarded by a threading.Lock in both the async and sync trees -- loop-agnostic, since it never needs to be released across an await -- so _increment_suspicious_counts is a plain def in the async tree too, not async def.

  • get_cached_detection_result caches the per-request DetectionResult on request.state, keyed on the identity of both the request and the route_config object it was computed for (cached[0] is request and cached[1] is route_config), so detect_penetration_attempt runs at most once per request regardless of how many checks (escalate_identity_violation, SuspiciousActivityCheck) need the result, and a cache entry can never be served to an unrelated request or route.

  • escalate_identity_violation's exception handler wraps every logger.exception(...) call (both the top-level failure log and the ip_ban_failed event-bus report) in its own try/except, so a failure in the logging sink itself (a broken handler, a full disk) cannot escape the handler and turn the caller's already-decided block response into an unhandled exception.

  • fetch_azure_ip_ranges in the prior release had no elapsed-budget concept at all: the page-scrape request used a fixed 10-second timeout, and the JSON download used a fixed 30-second timeout retried up to 3 times with a flat 2-second sleep between attempts, so the theoretical worst case for the whole call was around 104 seconds (10 + 3x30 + 2x2). fetch_azure_ip_ranges now computes a single deadline (_AZURE_DOWNLOAD_MAX_ELAPSED_SECONDS, 20 seconds) at the start of the call and sizes both the page-fetch timeout and every download-attempt timeout from the time remaining against it, so the worst-case wall time for the whole call is bounded by that single 20-second budget instead of the sum of several independently-fixed timeouts.

  • Azure download-URL discovery now tries three extractors in priority order -- an id="failoverLink" anchor, an href match, and a plain-text ServiceTags_Public_*.json URL match -- and validates every candidate URL against the same allowlist before using it: the scheme must be https and the parsed hostname must equal download.microsoft.com exactly, so a lookalike host such as download.microsoft.com.evil.com is rejected rather than matched as a prefix. The prior release had only the latter two extractors and validated neither, so a compromised or MITM'd page could point the download at an attacker-controlled host or an internal address and have the response fetched and trusted as Azure CIDR data. The download request itself (_download_azure_service_tags) now also passes allow_redirects=False (the prior release's inline download used aiohttp's, and in the sync tree requests's, default of following redirects), and any 3xx response now raises a ValueError before raise_for_status() or JSON parsing runs -- a passed-allowlist URL could otherwise still answer with a redirect to a completely different origin and have that response trusted, defeating the host allowlist regardless of how strict it is. A URL that genuinely redirects now fails the same way every other Azure fetch error already does: logged, an empty range set for that refresh, no false blocking of legitimate Azure IPs. When more than one dated ServiceTags_Public_*.json URL is present on the page, selection now parses the 8-digit date in the filename as a real calendar date (rejecting one that fails to parse or is in the future) and orders candidates deterministically (has a valid date, then the date itself, then the URL string), instead of picking whichever candidate the discovery regex happened to match first; a warning is logged after the winner is chosen when it has no parseable date at all, or when its date is more than 90 days old, so a silently-stale fallback pages an operator instead of running unnoticed. The retry loop that wraps this download also narrows what it retries: the prior release's single except Exception spanned the request, the status check, and the JSON parse, so a connection error, a bad HTTP status, and a malformed JSON body were all retried identically up to three times. _download_azure_service_tags now retries only session.get raising (a connection-level failure); a response that comes back with a bad status (raise_for_status()) or a body that fails to parse as JSON (response.json()) is outside the retried try/except and fails the attempt immediately, since retrying the same URL cannot change either outcome. A failed fetch still resolves to an empty range set either way, so this does not change false-blocking risk, only how many attempts (and how much added latency) a non-transient failure costs; test_fetch_azure_ip_ranges_download_failure is renamed to test_fetch_azure_ip_ranges_bad_status_is_not_retried to match, alongside a new test_fetch_azure_ip_ranges_bad_json_body_is_not_retried.

  • The country-shadow warning (warn_country_allowlist_shadows_blocklist, added in the prior release) is a model_validator(mode="after"), which -- since SecurityConfig does not set validate_assignment -- only ever ran at construction; a runtime assignment such as config.blocked_countries = ["CN"] after whitelist_countries was already set never re-checked the shadow condition at all. SecurityConfig.__setattr__ now re-runs the same check when either blocked_countries or whitelist_countries is assigned directly, deduplicated against the value already stored for that field so a periodic dynamic-rule re-sync that reassigns the same countries on every poll interval does not re-warn on every cycle; both sides of the dedup comparison are normalised to frozenset[str] first, since country fields are stored as frozenset[str] and comparing the raw incoming value directly against the stored frozenset would re-warn every time the same countries were reassigned as a list/tuple/set rather than a frozenset. The truthiness check (self.whitelist_countries and self.blocked_countries) runs before the field is mutated and revision is bumped, so a value whose __bool__ raises aborts the assignment with no observable state change instead of leaving the object partially written.

  • SecurityConfig.model_copy(update={...}) bypassed the same country-shadow check the entry above closes for direct assignment: base.model_copy(update={"blocked_countries": [...]}) on a base with a non-empty whitelist_countries produced a copy with both lists populated and no warning, since model_copy neither runs warn_country_allowlist_shadows_blocklist (a construction-only model validator) nor goes through __setattr__. model_copy now re-runs the same check via _warn_country_allowlist_shadows_blocklist whenever its update touches whitelist_countries or blocked_countries, evaluated against the copy's final state, mirroring the exclude_paths handling the override already had for its own field. Unlike the __setattr__ path, this one is not deduplicated against the base's prior value: a model_copy call is a one-shot snapshot, not a stream of reassignments to compare against itself, and a fresh copy with both fields populated is shadowed regardless of what the base looked like. deep=, other update keys, subclass identity, and the plain no-update call are unaffected.

  • On a request with no usable Content-Length (for example Transfer-Encoding: chunked), the prior release's fail-closed Content-Length gate (_body_exceeds_inspection_cap) always skipped body inspection outright, since there was nothing to size the body against. This release replaces that gate with a bounded-read system: _parse_content_length parses a present Content-Length (tolerating surrounding whitespace, rejecting a leading +, thousands separators, hex notation, and non-ASCII digit forms as malformed) to decide whether the body is small enough to read in full, and falls back to _read_capped_body_prefix -- reading only up to detection_max_body_inspect_bytes through the new BoundedBodyReader capability -- when Content-Length is absent, so a chunked request can now be inspected up to the cap instead of being skipped entirely. Both branches (Content-Length present or absent) now share one request.state cache, keyed on request identity the same way get_cached_detection_result is, so two independent readers on the same request in the same pipeline run (for example @guard.honeypot_detection([...])'s validator and SuspiciousActivityCheck's body scan) both see the same prefix -- and pay body_read_timeout at most once between them -- instead of the second one draining an already-consumed single-use stream, scoring no threat, or paying a second full timeout on a stalled read. When an adapter's read_body_prefix/body returns something other than bytes, the cache logs a warning naming the request type, the accessor, and the offending returned type before falling back to the same fail-closed, treated-as-unscannable outcome a raising reader already produces. Only a successful read is ever cached; a failure (the adapter raises, the read times out, or it returns something other than bytes) is never written to request.state, so the next reader on the same request always gets its own fresh attempt instead of being served a stale failure a retry would have turned into a real, scannable body -- a transient ConnectionResetError on the first consumer no longer permanently disables body inspection for every later consumer of the same request. A genuinely empty body (b"") is a successful read like any other and is cached and shared exactly the same way.

  • Body-read timeout (async tree only). Nothing bounded the wait on an adapter's read_body_prefix/body call: a stalled SSE producer, a long-poll that never yields, or a buggy adapter implementation left _safe_read awaiting forever, pinning the request (and, at volume, every worker handling one) indefinitely -- a real, unbounded DoS with no recovery short of a process restart. _safe_read (guard_core/utils.py) now wraps the call in asyncio.wait_for(reader(), timeout=timeout); on timeout it degrades to the identical fail-closed, could-not-evaluate outcome already used when the reader raises, through whichever caller's existing throttled logging already covers that path (BehaviorTracker._log_body_unavailable's TTLCache on the response side; the request side already returns None silently for a raising reader and continues to). This bound is configurable via SecurityConfig.body_read_timeout (default 3.0 seconds) and applies uniformly to BoundedBodyReader.read_body_prefix, BoundedResponseBodyReader.read_body_prefix, and the plain GuardRequest.body read, since all three route through _safe_read -- in the ASYNC guard_core tree only. The SYNC tree (guard_core.sync) bounds the same wait with a semaphore plus a joined daemon thread. A blocking adapter call can't be cancelled from the outside, so _safe_read in guard_core/sync/utils.py hands each read attempt to its own daemon=True thread and joins it for up to the remaining timeout budget; thread growth is capped by a threading.Semaphore(sync_body_read_max_concurrent) (default 64) that the caller must acquire, also bounded by timeout, before the thread is even started. SyncGuardRequest.body, SyncBoundedBodyReader.read_body_prefix, and SyncBoundedResponseBodyReader.read_body_prefix all route through it, and both SecurityConfig.body_read_timeout and SecurityConfig.sync_body_read_max_concurrent apply to the sync tree exactly as they do the async one. If the semaphore can't be acquired in time, guard-core logs the concurrency limit being reached and treats the body as unavailable for detection, the same fail-closed outcome used when the join itself times out; a timed-out thread is left to keep running in the background, unjoined, until the adapter's own call returns.

  • IpSecurityCheck.check() now also runs _check_global_ip_restrictions (with route_config=None, so no per-route override participates) for a request marked guard_exclusion_scoped by BypassHandler.handle_passthrough, after the dynamic ip_ban_manager check. Previously an excluded path reached only the banned-IP check; config.blacklist, config.whitelist, config.blocked_countries, and cloud-provider blocking were not enforced there, so a statically blacklisted IP got a 403 on a normal path but was served on an excluded one. _check_global_ip_restrictions gained an escalate keyword, and the exclusion-scoped call passes escalate=False, so a block on an excluded path never runs penetration detection to categorise it for threat_ban_config/auto_ban_threshold; route-level IP/country restrictions (_check_route_ip_restrictions) remain skipped on an excluded path, matching every other route-decorator-driven check. Which checks still run at all on an exclusion-scoped request is now a SecurityCheck.enforced_on_excluded_paths: ClassVar[bool] = False class attribute that SecurityCheckPipeline.execute reads directly off each check instance (True on RouteConfigCheck, IpSecurityCheck, and RateLimitCheck, the same three checks enforced there before this change), rather than a name list living apart from the check classes it would otherwise have to name; a test asserts the set of checks derived from DEFAULT_CHECK_CLASSES with the attribute set matches exactly those three.

  • guard_core.models.SecurityConfig.dynamic_rule_interval had no floor, while the AgentConfig field it is forwarded to (guard_agent.models.AgentConfig.dynamic_rule_interval) enforces ge=60. Setting it below 60 did not raise by default, since SecurityConfig and AgentConfig are validated independently and agent construction only raises on a rejected value when agent_strict=True; otherwise a too-low value silently disabled the entire agent integration instead of erroring. dynamic_rule_interval now also enforces ge=60, matching the floor AgentConfig already requires. Every other SecurityConfig field forwarded to AgentConfig in to_agent_config() was checked against the installed AgentConfig's own Field constraints for the same class of mismatch: agent_status_interval already carries ge=60, le=86400, at least as strict as AgentConfig.status_interval's ge=60; agent_buffer_size, agent_flush_interval, agent_max_concurrent_flushes, agent_timeout, agent_retry_attempts, agent_backoff_factor, agent_max_payload_size, agent_compression_threshold, and agent_high_watermark_ratio carry no bound on either side, so there is nothing for either side to drift from. dynamic_rule_interval was the only mismatch found.

  • The file_inclusion protocol-relative-URL pattern in guard_core/handlers/suspatterns_handler.py matched any //host substring, with no check for what preceded the //. Every ordinary absolute URL (https://example.com, http://api.example.com) contains // immediately after its scheme's colon, so any request body, header, or param carrying a webhook URL, a profile link, or a URL mentioned in prose was flagged as a file-inclusion attack. Present in every released version. The pattern now carries a (?<!:) negative lookbehind on the //, so a // preceded by : (i.e. part of a normal scheme:// URL) no longer matches; a scheme-less protocol-relative reference such as //evil.com/shell.txt or ?file=//evil.com/x.txt, which is the actual RFI shape this pattern exists to catch, is unaffected, since nothing precedes its //. Dangerous URL schemes (php://, data://, zip://, phar://, etc.) are matched by a separate pattern immediately above this one and were never affected.

  • Fixing the pattern above removed an accidental side effect it had been relied on for: two SSRF seed payloads in the attack-simulation benchmark (http://169.254.169.254/latest/meta-data/, the AWS/GCP/Azure cloud-metadata endpoint, and http://localhost:8080/admin) were detected only because the over-broad file-inclusion pattern happened to match their //, not because the dedicated ssrf pattern actually matched them — it never did. That ssrf pattern's private/link-local branch appended exactly one more \d+ octet after a two- or three-octet prefix and then required a \s|$|/ boundary immediately after it, which no real four-octet IPv4 address (or a host:port such as localhost:8080) can satisfy; every "detection" credited to it for a real dotted-quad private IP or a localhost/loopback address with a port was actually coming from the unrelated file-inclusion pattern. The pattern now matches a full remaining octet run per prefix (169\.254(?:\.\d{1,3}){2}, 192\.168(?:\.\d{1,3}){2}, 10(?:\.\d{1,3}){3}, 172\.(?:1[6-9]|2[0-9]|3[01])(?:\.\d{1,3}){2}) and tolerates an optional :port before the boundary, so http://169.254.169.254/latest/meta-data/, http://localhost:8080/admin, and real 10.x.x.x/172.16-31.x.x/192.168.x.x targets are now matched by the ssrf category itself. The attack-simulation benchmark's detection_rate is unchanged (0.8568), now for the correct reason.

  • The cmd_injection shell-substitution pattern's separator character class was [;&|`], so a markdown code-span backtick wrapping a $()/${} reference (backtick, $(id), backtick) was matched as a shell separator immediately followed by a command/variable substitution, flagging ordinary documentation and support-ticket text such as "see the backtick-wrapped $(id) example" or "use backtick-wrapped ${HOME} in paths" as command injection. The class is now [;&|]; a real separator-prefixed substitution (; $(id), | $(whoami), & ${HOME}, with or without a space) still matches. A bare backtick-wrapped substitution with no leading separator is no longer detected by this pattern, which is not a regression: backtick and $()/${} are alternative, non-nesting shell substitution syntaxes, so wrapping one in the other is not a realistic attack shape, and a bare backtick-wrapped command with no $()/${} inside it was never matched by this pattern either way.

  • BehaviorTracker._check_response_pattern guarded response-body access with hasattr(response, "body"). hasattr swallows exceptions, and a framework adapter's GuardResponse.body is a property that raises AttributeError for a response whose body is not fully materialized (a streaming response in particular), so a body that could not be read was indistinguishable from a body that was absent. Every json:, regex:, and bare-substring return_pattern rule evaluated against such a response therefore silently returned False, a clean "no match" the code never actually computed, with only a throttled log line as a hint that something was being skipped. status: patterns, which read response.status_code and never touch the body, were never affected. _check_response_pattern no longer touches .body or hasattr for the body-reading formats at all: it now requires the response to implement the BoundedResponseBodyReader capability, detected with an explicit isinstance check rather than a property probe (safe against the same raising-property problem, since an isinstance check against a runtime_checkable Protocol never invokes a method member, only a property member), gated by the opt-in SecurityConfig.behavior_scan_response_body flag (default False, so upgrading changes nothing until it is turned on) and bounded by SecurityConfig.body_read_timeout (see above). When the flag is off, the capability is absent, read_body_prefix raises or times out, or it returns something other than bytes, _check_response_pattern returns None: a could-not-evaluate outcome distinct from False, logged through the same throttled TTLCache(maxsize=1000, ttl=300) this warning already used (keyed by pattern, at most once per five minutes per distinct pattern). track_return_pattern folds None into "no occurrence recorded", the same as False, so a rule that cannot be evaluated still never reports a match it did not observe -- it just also never records a false one. BehaviorTracker does not cache the response-body prefix it reads. An earlier iteration cached it in a weakref.WeakKeyDictionary keyed on the response object itself, on the theory that multiple return_pattern rules checked against the same response in one pipeline run should share a single read; that cache is removed, because measured production demand for it was exactly one return_pattern rule, and it broke correctness for every deployment paying its cost: a response type using __slots__ without __weakref__ (a realistic shape for a lightweight adapter wrapper) raised TypeError on the weakref.ref() the dict requires, which the caller's outer except Exception swallowed into a silent, permanent, process-lifetime False ("no match") for every return_pattern rule evaluated against that adapter's responses, logged only via an untethered logger.warning/logger.error on every single call rather than the same throttled TTLCache this code already uses elsewhere; and a response wrapper object whose identity is pooled/reused across logically distinct responses (kept alive specifically so it can be reused, which is exactly what defeats a weak reference ever expiring the stale entry) served the first response's cached body prefix to a pattern check running against a completely different, later response. Each return_pattern rule checked against a response now performs its own independent, bounded read through _safe_read; a deployment with several such rules configured against the same response pays that read once per rule instead of once per response, the accepted tradeoff for removing a cache that was actively wrong rather than merely redundant.

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

  • The nine fields named above are now closed the same way global_behavior_rules was: an immutable type plus the identical __setattr__/model_copy re-validation wiring. whitelist (tuple[str, ...] | None, keeping its None "no whitelist" sentinel), blacklist, and trusted_proxies (both tuple[str, ...]) replace list[str], so .append()/.extend()/.insert()/slice-assignment now raise AttributeError/TypeError instead of mutating an unvalidated list. enabled_detection_categories, muted_event_types, muted_metric_types, and muted_check_logs replace set[str] with frozenset[str], so .add()/.discard()/.update() raise the same way. threat_ban_config replaces dict[str, ThreatBanConfig] with types.MappingProxyType[str, ThreatBanConfig] -- the standard library's read-only mapping view, since Python has no built-in frozen dict -- so config.threat_ban_config["xss"] = ThreatBanConfig(...) now raises TypeError ("'mappingproxy' object does not support item assignment") instead of silently bypassing the category-membership check validate_threat_ban_config already enforced at construction. block_cloud_providers replaces set[str] | None with frozenset[str] | None, closing the same in-place-mutation gap, and validate_cloud_providers now raises ValueError naming any entry whose provider name (the part before an optional :!region suffix) is not AWS/GCP/Azure, instead of silently dropping it -- this field was already unsafe at construction, not only on later mutation: SecurityConfig(block_cloud_providers={"AWS", "GPC"}) previously left GPC traffic completely unblocked with no error, warning, or log line anywhere. Every one of the nine fields' field_validators moved to mode="before" so the identical coerce-and-validate function backs both the constructor path (via Pydantic) and the new assignment/model_copy path (called directly), the same shared-function shape _validate_exclude_paths_value already established: config.whitelist = ["not-an-ip"] and base.model_copy(update={"threat_ban_config": {"bogus": ThreatBanConfig(threshold=1, duration=1)}}) both now raise the identical ValueError construction would, and a rejected reassignment leaves the field and revision unchanged, the same no-partial-state guarantee exclude_paths/global_behavior_rules already provide.

  • validate_geo_ip_handler_exists is closed the same way the country-shadow check was closed in the prior release: SecurityConfig.__setattr__ and model_copy now re-run it whenever blocked_countries, whitelist_countries, geo_ip_handler, or ipinfo_token changes after construction, evaluating the merged final state the same way construction does. config.blocked_countries = ["US"] on a config with no geo_ip_handler and no ipinfo_token now raises ValueError ("geo_ip_handler is required if blocked_countries or whitelist_countries is set") immediately instead of setting the field and leaving the country check silently unconsulted at request time; the field and revision are left unchanged on the raise. The construction-time convenience of auto-building an IPInfoManager from a set ipinfo_token is preserved on the assignment path too: reassigning blocked_countries/whitelist_countries on a config that already carries ipinfo_token (and no geo_ip_handler) auto-constructs the handler the same way construction does, and assigning config.geo_ip_handler = None while country rules are still active with no token set is rejected the same way the missing-handler case at construction is. model_copy(update={...}) re-runs the same check against the copy's fully merged state, so a single call that sets both keys together, base.model_copy(update={"blocked_countries": ["US"], "geo_ip_handler": handler}), still succeeds.

  • guard-core's ssrf category matched a target address only when it was written as a literal dotted-quad, localhost, or a small fixed set of hostnames, so an alternate encoding of an address already on that blocklist was not recognised at all: http://2130706433/, http://0177.0.0.1/, and http://0x7f.1/ all resolve to 127.0.0.1, which the literal pattern blocks, yet none of them matched anything. A new _decode_legacy_ipv4_host (guard_core/handlers/suspatterns_handler.py) implements the BSD inet_aton one-to-four-part decimal/octal/hex grammar and range-checks the decoded 32-bit address against the same private/link-local networks the dotted-quad pattern already recognises (0.0.0.0/8, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16), plus Alibaba Cloud's 100.100.100.200/32. Cloud metadata coverage had been AWS-only (169.254.169.254); GCP's metadata.google.internal and metadata.goog, and the same Alibaba address, are now matched by the hostname pattern alongside it. A single bare decimal integer under _MIN_BARE_DECIMAL_LEGACY_IPV4 (1 << 24, 16,777,216) is excluded from the decoded-address check, since every integer in that range decodes into 0.0.0.0/8 and the entire TCP port range would otherwise be reported as an SSRF target -- redis://6379, grpc://50051, and amqp://5672 are connection strings, not addresses, and are not flagged. The one value excluded from that exclusion is the canonical decimal form of 0.0.0.0 itself: http://0/ and http://0:8080/ still resolve to loopback and are still detected, rather than being swept into the same port-number carve-out as the connection strings around it.

  • ContentPreprocessor._decode_base64_candidates (guard_core/detection_engine/preprocessor.py) decoded any 20-plus character run of the base64 alphabet with errors="ignore", which discarded exactly the invalid bytes that would have revealed a decode as garbage rather than genuine content; an ordinary REST path or identifier can easily be 20-plus base64-alphabet characters, so this silently corrupted content before any pattern ever saw it, measured at 55 of 69 realistic strings, including S3 object keys, git SHAs, session tokens, content-hash filenames, and GCP's computeMetadata/v1/instance/ path, which decoded to mojibake. A corrupted string cannot match a signature, so this was a detection hole, not a cosmetic one. Decoding now requires a strict UTF-8 decode (which raises, rather than silently discarding bytes, on invalid input) and a minimum printable-ASCII ratio (_PRINTABLE_ASCII_RATIO_THRESHOLD, settled at 0.5 after measurement showed the strict UTF-8 decode alone did nearly all the work), and a token that fails either check is left exactly as it was rather than replaced with the garbage decode; corruption on the same 69-string sample drops to 3, all JWTs, which decode to their own JSON claims, so content injected into a claim stays scannable, and recall on a 47-payload base64-wrapped attack corpus is unchanged. A genuinely undecodable byte is no longer an automatic reject: a lossy decode (errors="replace") is attempted, and accepted or rejected by how much of the result it corrupts (_MAX_REPLACEMENT_CHAR_RATIO = 0.2, a share of the decoded text) rather than a fixed count of replacement characters, so a short payload carrying one bad byte and a long payload carrying several are judged by the same standard rather than the long payload being rejected outright once it crosses a fixed count; this closes a gate an attacker could otherwise use deliberately, padding a payload with enough invalid UTF-8 bytes to guarantee it stays hidden as opaque base64, regardless of how much of the surrounding content was perfectly valid. Hex literals (0x7f...) are exempted from base64 decoding by their 0x prefix specifically; a token that merely looks like hex (an even-length run of 0-9a-f with no 0x prefix) is not exempted, since base64's own alphabet overlaps the hex-safe character set closely enough that an attacker could otherwise choose plaintext whose encoding happens to land in it and skip decoding entirely.

  • Every regex pattern in SusPatternsManager is compiled with re.IGNORECASE | re.MULTILINE (guard_core/handlers/suspatterns_handler.py, both the PatternCompiler-backed path and the legacy no-compiler path), so a pattern anchored with a bare ^/$ does not anchor to the start/end of the whole scanned content, it anchors to the start/end of any line within it. 25 patterns across recon, cms_probing, sqli, cmd_injection, and ssrf used a bare ^/$ this way, so a benign multi-line document containing one line that happened to match a pattern's keyword tripped it regardless of the rest of the document. Verified against the pre-fix code: an internal routes list containing a line reading exactly /version was reported as recon; a workspace role list containing a line reading exactly administrator was reported as cms_probing; a migration note ending a line in ORDER BY 2 was reported as sqli; a shell-usage example ending a line in echo 'debug' # was also reported as sqli; and a deployment-script excerpt containing the line sh -x deploy.sh was reported as cmd_injection. Anchoring is now decided per pattern instead of applied uniformly: a pattern whose content is genuinely expected to be a path (sensitive_file, and most of recon/cms_probing's single-token patterns) is anchored to the whole string (\A...\Z); a pattern that needs to fire on a probe appearing inside a larger document instead requires an explicit scheme://host/ prefix (cms_probing's wp-admin/administrator/xmlrpc pattern, so a URL mentioned in prose is still caught while a bare mention of "backing up the .htaccess file" is not) or a narrower context-sensitive alternative (sqli's ORDER BY/comment patterns now also match immediately after a =, ?, or & even mid-body; cmd_injection gained a pattern for a shell invocation with the -c flag preceded by a newline). This preserves the embedded-attack detection a uniform whole-string anchor would otherwise have removed for content that is, in practice, never nothing but the attack payload -- verified for command injection, SQL injection, and CMS probing embedded inside a larger request body. The two ssrf patterns that also use bare ^/$ are left as they are: their adjacent \s alternative already consumes a newline as a boundary regardless of the MULTILINE flag, so anchoring them would have been a no-op.

  • Separately, the recon category's management/system/version/config_dump/credentials probe pattern matched only a single top-level path segment (/management), so the same probe nested under an application prefix (/app/management/health, /v2/system/version) went undetected. The pattern now matches the same keywords at any depth under a genuine absolute path, while still requiring the path to actually start with /, so a home-directory reference such as ~/.aws/credentials (a credentials-exposure concern, not a reconnaissance probe) is not swept into the wider match.

  • guard-core's ldap category had no pattern for a filter-injection payload that opens with a wildcard rather than a leading (&/(| conjunction: cn=*)(uid=* and *)(password=*), both classic LDAP authentication-bypass shapes, went undetected. A new pattern requires the literal *)( to be immediately followed by an attribute name and = (\*\)\(\s*[a-zA-Z][\w-]*\s*=), which matches both payloads while leaving ordinary prose that merely contains the same three characters in sequence -- an arithmetic aside (total = a*)(b+c)) or a footnote marker (See appendix A*)(footnote 3) for details) -- unmatched, since neither is followed by an identifier and =.

Behaviour changes

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

Documentation

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

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