v2.9.0
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\AbuseIpdbchecks 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 reachesthreshold(75 by default — AbuseIPDB's own "strong enough to act on" mark). Requires a free API key; with noapi_keythe plugin matches nothing, so it is safe to add to a config before the key is provisioned. It is not in any preset and shipsenable: falsein 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 theIpAddressplugin. -
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 and127.0.0.1in 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 fulltimeoutbefore 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
Crsprecedent 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_ruleson CRS log lines (#95). The rule IDs that actually detected something, in match order, with the blocking-evaluation bookkeeping dropped. Present on both theinfoblock line and thedebugnon-blocking line. These — notrule_id— are what belong indisabled_rules. See the upgrade notes. -
CRS coverage-gap warnings (#95). The plugin now logs at
warninglevel when the engine reportsoperator_errorsortruncations, 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-enginerequirement bumped from^0.1.0to^1.0(#95).^0.1.0excludes 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_thresholdsrenamed toinbound/outbound(#95, fixes #93). The key accepted four severity-named thresholds and read two.criticalwas the single inbound threshold anderrorthe outbound one;warningandnoticewere 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-engine1.0.0's deprecation notice for those names out of your logs. When both spellings of one threshold are present,inbound/outboundwin, so config migrated one key at a time behaves the same in either write order. Any other key — including thewarningandnoticeour own README told people to write — now logs a warning naming what was ignored and pointing atdisabled_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
ConfigurationExceptionon 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
blockaction — 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, theCrsclass docblock andexample/config.notes.ymlare 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 atinbound: 5and allowed atinbound: 500, same score either way. -
CRS category list gained
blocking_evaluation,response_leakandresponse_leak_ruby(#95), all now documented fordisabled_categories.blocking_evaluationis 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-engine1.0.0 keys thresholdsinbound/outboundand warns on the severity spellings. The plugin normalised tocritical/errorand always passed both keys explicitly, so it fired twoE_USER_DEPRECATEDper plugin instance for every user — including those with noanomaly_thresholdsconfig 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'sgetLastVerdict()example saidevaluate()returns FALSE on a block and assigned it to$allowed;example/config.notes.ymldescribedmodein the same inverted terms. Both corrected.
Upgrade notes
-
composer update kanopi/firewallpullscrs-engine1.0.0. No configuration change is required for the upgrade to install, and no existing key stops working. -
If you run the
Crsplugin, roll this out onmode: monitorfirst. In 0.1.0 the anomaly-evaluation rules could never fire,TX:targets never resolved, andskipAftersilently 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 thecontributing_ruleson thedebuglog lines by rule ID, and build yourdisabled_ruleslist from what you actually see before letting it block. CRS at paranoia 1 does flag ordinary editorial content — a support ticket containingcat /etc/hosts | grepscores 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_idon 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 takerule_idand put it indisabled_rules— following that now disables anomaly blocking wholesale. Usecontributing_rulesfrom the same log line instead. Same caution forblocking_evaluationindisabled_categories. -
Migrating
anomaly_thresholdsis optional.critical→inboundanderror→outboundif you want the honest names. Remove anywarningornoticekeys — 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.ymlwithenable: falseand is in no preset. If you enable it, provision the key through%env(ABUSEIPDB_API_KEY)%rather than committing it — that is the syntaxTokenSubstituteresolves 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
-
AbuseIpdbhas 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 byfetch()count rather than by trusting the code path.fetch()is the single network seam, which is what makes the suite hermetic. -
PluginPolarityTestcoversAbuseIpdb(#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 inLoggingFactoryTest, unrelated to this release.
Full Changelog: v2.8.1...v2.9.0