Repository navigation
2.0.0
Guard Core v2.0.0 — Release Notes
Why this release
Four operator-facing capabilities ship together because they form a coherent surface for tuning detection in production:
- Carve out false-positive-prone fields. Some headers, query params, and JSON body fields legitimately contain content that looks like XSS or SQLi (CSRF tokens, search queries, free-text notes). Operators can now exclude them per-route or globally without disabling detection wholesale.
- Tune ban policy by threat type. A single ban threshold across all categories is too blunt: SQLi might warrant a 7-day ban after one hit; XSS in a search box might warrant 24 hours after three hits.
ThreatBanConfigmakes that per-category. - Correlate noise across rules. A single 404 is nothing; 20 of them in 5 minutes from an IP that's also tripping pattern detection is a probe. The new global behavior rules + correlation flag halve the threshold when an IP is already flagged.
- Decouple cloud-IP refresh from app startup. Containers booting with cold IPInfo/AWS data can now defer those fetches to first request, and horizontally-scaled deployments can pre-populate a shared
RedisCloudIpStoreinstead of every replica fetching independently.
Plus a DetectionResult dataclass replacing the legacy (bool, str) tuple, so threat categories and scores are addressable everywhere they're produced.
What's in the box
Area 1 — Detection exclusion knobs
SecurityConfig.excluded_detection_headers / _params / _body_fields(global) and matchingRouteConfigfields withNone= inherit semantics.SecurityConfig.enabled_detection_categories(defaults to all 16) with a validator that rejects unknown labels.@security.detection_exclusion(headers=, params=, body_fields=, categories=)decorator.ALL_DETECTION_CATEGORIESfrozenset andCATEGORY_CONTEXT_MAPconstant for downstream code that wants to enumerate or reason about categories.- Custom user-added patterns are tagged
"custom"and run regardless ofenabled_categoriesfiltering.
Area 4 — Per-category ban + DetectionResult
DetectionResult(is_threat, trigger_info, threat_categories, threat_scores)dataclass replacestuple[bool, str]everywheredetect_penetration_attemptanddetect_penetration_patternswere used.ThreatBanConfig(threshold, duration)model with category validator.SecurityConfig.threat_ban_config: dict[str, ThreatBanConfig]— first category to cross its own threshold short-circuits the flat fallback.- Ban reasons tagged
"penetration_attempt:<category>"(per-category) or"penetration_attempt"(flat). GuardMiddlewareProtocol.suspicious_request_countsnowdict[str, dict[str, int]](IP → category → count). Total still reachable viasum(values()).BehaviorRule.ban_duration(consumed by_execute_ban_action, defaults to 3600 when unset).
Area 2 — Global behavior rules + 404-noise correlation
BehaviorRuleConfigPydantic model:rule_type,threshold,window,pattern,action,ban_duration,correlate_with_detection.SecurityConfig.global_behavior_rules: list[BehaviorRuleConfig]— applies to every route, no decorator needed.BehavioralProcessor.process_global_return_rules()halves the rule's threshold (floor 1) whencorrelate_with_detection=TrueAND the IP has any positive count insuspicious_request_counts.BehavioralContext.middlewarefield; tracker access uses the_behavior_tracker()precedence helper (context first, decorator fallback) so global rules work without a decorator.ErrorResponseFactory.process_response()accepts an optionalprocess_global_behavioral_rulescallback;client_ipis extracted once and shared.
Area 3 — IP lifecycle + pluggable cloud-IP store
SecurityConfig.lazy_init: bool = Falsedefers IPInfo MMDB download and cloud-IP fetches to the first request.SecurityConfig.geo_ip_db_max_age: int = 86400(validated 3600 ≤ x ≤ 604800) replaces the hardcoded 86400 inIPInfoManager.SecurityConfig.cloud_ip_store: CloudIpStoreProtocol | None = None— pluggable backend.IPInfoManager.refresh()andGeoIPHandler.refresh()/close()protocol methods.CloudIpStoreProtocolwithInMemoryCloudIpStore(default) andRedisCloudIpStore(auto-upgraded when Redis is wired).CloudManager.set_store(...), store-first reads with API-fetch + write-back fallback. Legacyredis_handler-only path preserved when_store is None.- Strict protocol typing:
redis_handler: RedisHandlerProtocol | None,agent_handler: AgentHandlerProtocol | Noneinstead ofAny. - Redis cache namespace migrates from
cloud_ranges(CSV) toguard:cloud_ip(JSON list).
Tooling cleanup
- Vulture clean (0 findings down from 10):
schemeProtocol parameter whitelisted; 4unreachable code after raisecases intest_handlers_integration.pyrewritten as class-based context managers (no deadyield). setup_custom_loggingcloses file handlers explicitly — fixes aResourceWarningfor_io.FileIOthat was emitted underpytest -W error::ResourceWarning.unasync.pyrewrite rule for multi-linefrom tests.conftest import (...)and a new substitution rule forcloud_ip_store_protocol(with properSyncCloudIpStoreProtocolrename matching project convention).tests/conftest.pyredis_cleanupfixture now teardowns Redis state afteryieldin addition to before — removes a previously-hidden test-order dependency.
Breaking changes — migration recipes
1. detect_penetration_attempt() returns DetectionResult
# Before
detected, trigger = await detect_penetration_attempt(request)
# After (minimal)
result = await detect_penetration_attempt(request)
detected, trigger = result.is_threat, result.trigger_info
# After (using the new info)
result = await detect_penetration_attempt(request)
if result.is_threat:
log.warning("Threat: %s, categories=%s, scores=%s",
result.trigger_info,
result.threat_categories,
result.threat_scores)Same migration applies to detect_penetration_patterns().
2. suspicious_request_counts is now nested
# Before
self.suspicious_request_counts[ip] += 1
total = self.suspicious_request_counts[ip]
# After (per-category increment)
self.suspicious_request_counts.setdefault(ip, {})
counts = self.suspicious_request_counts[ip]
counts[category] = counts.get(category, 0) + 1
# After (reading the total)
total = sum(self.suspicious_request_counts.get(ip, {}).values())3. Compiled-pattern tuples are 3-tuples
# Before
for pattern, contexts in sus_patterns_handler.get_all_compiled_patterns():
...
# After
for pattern, contexts, category in sus_patterns_handler.get_all_compiled_patterns():
...4. Redis cache namespace
No code change needed. On upgrade, the old cloud_ranges keys go stale (TTL expires naturally) and the new guard:cloud_ip namespace is populated on next refresh. If you have ops dashboards reading the old keys directly, point them at guard:cloud_ip:* instead.
Compat notes
lazy_init=Falseis the default — existing eager startup is preserved.enabled_detection_categoriesdefaults to the full set — detection coverage is unchanged unless explicitly narrowed.threat_ban_configdefaults to empty — flat-threshold behaviour preserved unless per-category entries are added.
Test posture
- 3124 tests, 4 deselected (Docker-backed integration tests run separately via
make integration-test). - 100% line + 100% branch coverage on every modified file and overall.
- Zero pytest warnings.
- Pre-commit chain (ruff-format, ruff, mypy guard_core, vulture, bandit, radon, xenon, deptry) all green.
- Vulture: 0 findings (down from 10 pre-existing).
- mypy guard_core: clean across 172 source files.
What's Changed
Full Changelog: 1.2.1...2.0.0