Skip to content

2.0.0

Choose a tag to compare

@rennf93 rennf93 released this 25 Apr 19:11
· 337 commits to master since this release
975bebe

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:

  1. 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.
  2. 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. ThreatBanConfig makes that per-category.
  3. 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.
  4. 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 RedisCloudIpStore instead 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 matching RouteConfig fields with None = 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_CATEGORIES frozenset and CATEGORY_CONTEXT_MAP constant for downstream code that wants to enumerate or reason about categories.
  • Custom user-added patterns are tagged "custom" and run regardless of enabled_categories filtering.

Area 4 — Per-category ban + DetectionResult

  • DetectionResult(is_threat, trigger_info, threat_categories, threat_scores) dataclass replaces tuple[bool, str] everywhere detect_penetration_attempt and detect_penetration_patterns were 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_counts now dict[str, dict[str, int]] (IP → category → count). Total still reachable via sum(values()).
  • BehaviorRule.ban_duration (consumed by _execute_ban_action, defaults to 3600 when unset).

Area 2 — Global behavior rules + 404-noise correlation

  • BehaviorRuleConfig Pydantic 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) when correlate_with_detection=True AND the IP has any positive count in suspicious_request_counts.
  • BehavioralContext.middleware field; tracker access uses the _behavior_tracker() precedence helper (context first, decorator fallback) so global rules work without a decorator.
  • ErrorResponseFactory.process_response() accepts an optional process_global_behavioral_rules callback; client_ip is extracted once and shared.

Area 3 — IP lifecycle + pluggable cloud-IP store

  • SecurityConfig.lazy_init: bool = False defers 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 in IPInfoManager.
  • SecurityConfig.cloud_ip_store: CloudIpStoreProtocol | None = None — pluggable backend.
  • IPInfoManager.refresh() and GeoIPHandler.refresh() / close() protocol methods.
  • CloudIpStoreProtocol with InMemoryCloudIpStore (default) and RedisCloudIpStore (auto-upgraded when Redis is wired).
  • CloudManager.set_store(...), store-first reads with API-fetch + write-back fallback. Legacy redis_handler-only path preserved when _store is None.
  • Strict protocol typing: redis_handler: RedisHandlerProtocol | None, agent_handler: AgentHandlerProtocol | None instead of Any.
  • Redis cache namespace migrates from cloud_ranges (CSV) to guard:cloud_ip (JSON list).

Tooling cleanup

  • Vulture clean (0 findings down from 10): scheme Protocol parameter whitelisted; 4 unreachable code after raise cases in test_handlers_integration.py rewritten as class-based context managers (no dead yield).
  • setup_custom_logging closes file handlers explicitly — fixes a ResourceWarning for _io.FileIO that was emitted under pytest -W error::ResourceWarning.
  • unasync.py rewrite rule for multi-line from tests.conftest import (...) and a new substitution rule for cloud_ip_store_protocol (with proper SyncCloudIpStoreProtocol rename matching project convention).
  • tests/conftest.py redis_cleanup fixture now teardowns Redis state after yield in 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=False is the default — existing eager startup is preserved.
  • enabled_detection_categories defaults to the full set — detection coverage is unchanged unless explicitly narrowed.
  • threat_ban_config defaults 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

  • Feature: Pattern matching, global behaviour rules, IP lifecycle, Ban config by @rennf93 in #8

Full Changelog: 1.2.1...2.0.0