0.5.0 - Anomaly scoring, false-positive tuning, match logging
This release replaces first-match blocking with CRS-style anomaly scoring, adds the tooling to tune false positives away instead of disabling rules, makes every match observable through PSR-3 logging, and hardens the engine against evasion and information leaks. The bundled rule snapshot was re-imported from CRS v4.29.0 and grew from 185 to 207 rules (196 CRITICAL, 10 WARNING, 1 NOTICE).
Anomaly scoring
Evaluation now follows the CRS anomaly-scoring model: every matching rule contributes its severity score (CRITICAL 5, ERROR 4, WARNING 3, NOTICE 2; a missing severity counts as CRITICAL), and the request is blocked once the accumulated score reaches the threshold (default 5, the CRS standard). In practice CRITICAL rules still block on their own; the 10 WARNING rules and the single NOTICE rule - classic false-positive sources such as the 942430 restricted-character checks - no longer block solo, which resolves the well-known utm_*/fbclid false positives. Fail-closed decisions (a capped variable, an oversized value, a PCRE error) block immediately regardless of the score.
Both presets accept an anomalyThreshold; anomalyThreshold: 1 restores block-on-first-match. Blocked requests carry full scoring metadata (owasp_anomaly_score, owasp_anomaly_threshold, owasp_rule_ids, owasp_log_data, owasp_fail_closed), and with diagnostics headers enabled the response reports X-Phirewall-Owasp-Rule (up to 10 ids, then ,+N) and X-Phirewall-Owasp-Score (score/threshold).
False-positive tuning
- Target exclusions remove a parameter from inspection instead of disabling a whole rule:
excludeTarget('ARGS:/^utm_/')globally,excludeTargetById(942431, 'ARGS:fbclid')per rule (CRSSecRuleUpdateTargetByIdstyle),excludeTargetByTag('attack-sqli', 'ARGS:comment')per tag. Exclusions are runtime configuration - never part of the compiled cache - and cannot lift the collection cap. - Value manipulators (
addManipulator()/addManipulatorById()) transform collected values before rules match, as the advanced escape hatch where excluding a whole parameter is too broad; returning an empty string removes the value. Documented with an explicit warning: manipulators weaken detection. - Both presets accept a
configure:closure receiving theCoreRuleSetMatcher, so exclusions, manipulators and rule toggles can be applied before the rules load.
Match logging (PSR-3)
Both presets and CoreRuleSetMatcher accept a logger. Every rule match is logged at info level with rule id, severity, score, paranoia level, matched variable and the rule's logdata: template expanded (%{TX.0}, %{MATCHED_VAR_NAME}, %{MATCHED_VAR}) - including sub-threshold matches on requests that pass, which is the signal for finding false-positive patterns before they block. Blocked requests additionally log a warning with the total score and all matched rule ids. New dependency: psr/log.
Expanded rule coverage
Named and negated rule-text selectors are honored: REQUEST_HEADERS:User-Agent collects exactly that header, and CRS exclusions such as !REQUEST_HEADERS:Cookie (16 shipped rules) now take effect, removing a source of cookie-driven false positives. The re-imported snapshot therefore includes previously dropped named-header and scanner rules (among them the 913100 scanner detection). REQUEST_URI_RAW targets are remapped to REQUEST_URI, and (?i) is injected into @rx patterns that relied on t:lowercase, so URL-path traversal and mixed-case gadget payloads no longer slip through.
Security hardening
- Oversized values fail closed. A single collected value longer than 2048 bytes is un-inspectable and blocks for every operator, closing a padding-based
@rxevasion and bounding worst-case regex backtracking. Configurable viasetMaxInspectableValueLength()for deployments with legitimately large single values. - Credentials are redacted from logs and metadata. Matched values of cookies and of
Authorization,Cookie,Proxy-Authorization,X-Api-KeyandX-Auth-Tokenheaders appear as[redacted]in expanded logdata; the target name is kept so the parameter stays identifiable for tuning. REQUEST_FILENAMEcollects the full request path instead of only the basename, so the restricted-files rules match/.git/config,/.envand similar probes again.@pmFromFileis confined and fails closed. Unsafe operands (directory traversal, absolute paths, stream wrappers, context escapes) are rejected and block deterministically instead of throwing - an exception would previously have disabled the whole rule set for that request under the default fail-open policy. Each data-file line is treated as one phrase (ModSecurity semantics), fixing a severe false positive where normal browser user agents were blocked as scanners.- The matcher honors the firewall's fail-open policy (
FailOpenAware): with fail-open disabled, an unexpected engine fault blocks the request instead of letting it through. bin/crs-importhardening: optional--sha256tarball verification, a reproducibility warning when no--tagis pinned, byte limits on downloads and archive entries, and theGITHUB_TOKENis only ever sent to api.github.com.
Quality assurance
The shipped rules are now guarded behaviorally from both directions: a benign corpus asserts realistic legitimate traffic (browser and bot requests, tracking parameters, JWT cookies, form logins) stays below the threshold at every paranoia level, and an attack corpus asserts realistic attacks across eight classes are blocked via their accumulated score. A rule-inventory diff gate fails the suite when a CRS re-import silently changes which rules ship, until the snapshot is deliberately regenerated. A known-evasions corpus pins the documented engine gaps (no raw JSON/XML body or upload inspection) so a change that closes one is surfaced. Five new runnable examples cover scoring, diagnostics headers, the fail-closed cap, credential redaction and tuning, and an Infection mutation-testing setup runs on a weekly schedule.
Breaking changes and upgrading
CoreRuleSet::match(): ?intwas replaced byevaluate(): RuleSetEvaluation; useanomalyThreshold: 1for the old first-match behavior.- Presets block at accumulated score >= 5 instead of on the first match; the fail2ban preset counts a request toward the ban only when that request itself reached the threshold.
VariableCollectorInterface::collect()returns named entries (list<array{name: ?string, value: string}>) instead of a flat value list.- A single value larger than 2048 bytes now blocks (previously it was partially inspected or passed); raise the limit via
setMaxInspectableValueLength()if you handle legitimately large single values. - The compiled-data cache schema was bumped; existing artifacts rebuild automatically.
- New dependency:
psr/log.