Skip to content

v2.9.0

Choose a tag to compare

@sean-e-dietrich sean-e-dietrich released this 29 Jul 15:58
950fa0d

Two changes: a new AbuseIPDB reputation plugin, and the CRS plugin now requires crs-engine ^1.0 — the first version of the engine that actually enforces. If you run the Crs plugin, read Upgrade notes before deploying. Traffic that passed under crs-engine 0.1.0 will now be rejected, because previously very little was.

Everything else is unchanged. No plugin is enabled by this release that was not enabled before, and existing configuration keeps working.

PHP 8.1–8.5 supported.

Added

  • AbuseIPDB reputation plugin (#96, fixes #16). Kanopi\Firewall\Plugins\AbuseIpdb checks the client IP against AbuseIPDB, which scores an address 0-100 by how confidently it has been reported for abuse, and matches once that score reaches threshold (75 by default — AbuseIPDB's own "strong enough to act on" mark). Requires a free API key; with no api_key the plugin matches nothing, so it is safe to add to a config before the key is provisioned. It is not in any preset and ships enable: false in the example config, so upgrading does not turn it on.

    - plugin: "Kanopi\\Firewall\\Plugins\\AbuseIpdb"
      response: block
      weight: 40          # Cheaper than CRS, dearer than a static IP list.
      enable: true
      config:
        api_key: "%env(ABUSEIPDB_API_KEY)%"
        threshold: 75

    Three behaviours are load-bearing enough to state here rather than leave in the README:

    • It fails open. A timeout, a refused key or a spent quota reports no match, logs at warning, and lets evaluation continue to the next plugin. Reputation is corroborating evidence, not the last line of defence, and a third-party outage must not become an outage on your site. Addresses you need blocked regardless of what AbuseIPDB says belong in the IpAddress plugin.

    • It is cached, because the quota is the constraint. The free tier allows 1,000 checks a day, which a modest site would exhaust before lunch at one call per request. Verdicts are cached per address for cache_ttl (24h), clean results included, so cost is roughly one call per unique visitor per day. Private and reserved ranges are never looked up, so a local or intranet deployment spends nothing and 127.0.0.1 in development is free. Cache entries are named by SHA-1 of the address, so client IPs are not readable from a directory listing.

      Failed lookups are cached too, for a much shorter error_cache_ttl (5 min). Without that, an outage would make every request wait out the full timeout before failing open — availability preserved on paper while the site crawls.

    • Allowed addresses never match, whatever they score. AbuseIPDB flags search-engine crawlers and similar known-good infrastructure, which also accumulate reports; blocking one would take out legitimate traffic.

    Bad config degrades to documented defaults with a warning naming the key, following the Crs precedent that a typo in a YAML file should not take a site down. Lookups use the stream wrapper rather than Guzzle, matching the one other place this package fetches over the network (Config::fileGetContents) and leaving a security library's dependency surface unchanged.

  • contributing_rules on CRS log lines (#95). The rule IDs that actually detected something, in match order, with the blocking-evaluation bookkeeping dropped. Present on both the info block line and the debug non-blocking line. These — not rule_id — are what belong in disabled_rules. See the upgrade notes.

  • CRS coverage-gap warnings (#95). The plugin now logs at warning level when the engine reports operator_errors or truncations, meaning a rule could not run or an inspection cap engaged. This fires even when nothing matched, because a clean verdict over a partly-uninspected request is exactly where a coverage gap gets believed.

Changed

  • kanopi/crs-engine requirement bumped from ^0.1.0 to ^1.0 (#95). ^0.1.0 excludes 1.0.0 outright, so this is required to install the fixed engine at all. 0.1.0 is not supported alongside it — the engine's own release notes describe its 0.1.0 detection pipeline as substantially inert. This is the change behind the behavioral difference described in the upgrade notes.

  • anomaly_thresholds renamed to inbound / outbound (#95, fixes #93). The key accepted four severity-named thresholds and read two. critical was the single inbound threshold and error the outbound one; warning and notice were documented, accepted, and read nowhere.

    anomaly_thresholds:
      inbound: 5     # was `critical` — request-side rejection threshold
      outbound: 4    # was `error` — response-side equivalent (not reached yet, #69)

    The severity spellings still work, so existing config needs no change; the plugin translates them, which also keeps crs-engine 1.0.0's deprecation notice for those names out of your logs. When both spellings of one threshold are present, inbound / outbound win, so config migrated one key at a time behaves the same in either write order. Any other key — including the warning and notice our own README told people to write — now logs a warning naming what was ignored and pointing at disabled_rules, instead of looking like it worked. A non-numeric or non-mapping value falls back to the default and says so.

    The plugin is deliberately more forgiving than the engine here: 1.0.0 throws ConfigurationException on an unrecognised threshold key, while this plugin drops it, warns, and carries on protecting the site with the rest of the config.

  • The inbound threshold is now the real tuning lever, and the documentation said the opposite (#95). Under 0.1.0 the anomaly-evaluation rules could never fire, so the score never reached the threshold comparison and blocking fell back entirely to whichever rule carried a block action — thresholds of 5, 50 and 500 all rejected the same SQLi payload. 1.0.0 fixed that. The README's "Anomaly scores and thresholds" section, the Crs class docblock and example/config.notes.yml are rewritten accordingly, and the test written in #95 to fail when this got fixed has been replaced with its inverse: the same payload is rejected at inbound: 5 and allowed at inbound: 500, same score either way.

  • CRS category list gained blocking_evaluation, response_leak and response_leak_ruby (#95), all now documented for disabled_categories. blocking_evaluation is flagged as off-limits — disabling it stops anomaly scoring rejecting anything at all.

Fixes

  • The threshold shim emitted a deprecation on every request (#95). crs-engine 1.0.0 keys thresholds inbound/outbound and warns on the severity spellings. The plugin normalised to critical/error and always passed both keys explicitly, so it fired two E_USER_DEPRECATED per plugin instance for every user — including those with no anomaly_thresholds config at all. It now emits the canonical names. Verified at zero deprecations for empty, canonical, legacy, and legacy-plus-inert config.

  • Documentation stale since #91 inverted evaluate() (#95). The README's getLastVerdict() example said evaluate() returns FALSE on a block and assigned it to $allowed; example/config.notes.yml described mode in the same inverted terms. Both corrected.

Upgrade notes

  • composer update kanopi/firewall pulls crs-engine 1.0.0. No configuration change is required for the upgrade to install, and no existing key stops working.

  • If you run the Crs plugin, roll this out on mode: monitor first. In 0.1.0 the anomaly-evaluation rules could never fire, TX: targets never resolved, and skipAfter silently discarded the rest of the ruleset. 1.0.0 enforces, so traffic that used to pass will now be rejected. Let it run over real traffic, group the contributing_rules on the debug log lines by rule ID, and build your disabled_rules list from what you actually see before letting it block. CRS at paranoia 1 does flag ordinary editorial content — a support ticket containing cat /etc/hosts | grep scores 15 and would be rejected — so on a CMS this step is not optional.

    The default stays mode: block. Weakening an existing deployment's protection silently would be the worse default; the monitor-first rollout is documented instead.

  • rule_id on a CRS block is now always 949110. Blocking routes through the CRS rule that compares the accumulated score to the threshold, not the rule that caught the payload. The README previously told operators to take rule_id and put it in disabled_rulesfollowing that now disables anomaly blocking wholesale. Use contributing_rules from the same log line instead. Same caution for blocking_evaluation in disabled_categories.

  • Migrating anomaly_thresholds is optional. criticalinbound and erroroutbound if you want the honest names. Remove any warning or notice keys — they never did anything, and the plugin will now tell you so rather than accepting them silently.

  • Adding the AbuseIPDB plugin is opt-in. It appears in example/config.notes.yml with enable: false and is in no preset. If you enable it, provision the key through %env(ABUSEIPDB_API_KEY)% rather than committing it — that is the syntax TokenSubstitute resolves through nested plugin config, which ${VAR} is not. Place it after the static IP / user-agent / ASN filters and before CRS, so it only ever spends quota on traffic that got past the free checks.

Internal

  • AbuseIpdb has 22 unit tests, with call counts asserted throughout. How many times the plugin hits the API is a correctness property here, not an optimisation detail — the verdict cache, the failure cache, the non-routable-address skip and the unconfigured no-op are each verified by fetch() count rather than by trusting the code path. fetch() is the single network seam, which is what makes the suite hermetic.

  • PluginPolarityTest covers AbuseIpdb (#96). Its row is seeded through the real on-disk cache rather than a stubbed subclass, so the contract test exercises the shipped class with no network: one address pre-scored at 100, one at 0.

  • Two CRS tests pin the threshold behaviour in both modes (#95), so if the engine's blocking path changes again the documentation section fails the build rather than quietly going stale.

  • Suite: 834 unit tests and 49 integration tests pass. PHPCS, PHPStan at level max and Rector are clean. The 2 reported deprecations are a pre-existing ReflectionProperty::setValue() call in LoggingFactoryTest, unrelated to this release.

Full Changelog: v2.8.1...v2.9.0