kanopi / crs-engine
Standalone PHP engine that parses the OWASP Core Rule Set (CRS) and evaluates HTTP requests against it.
Requires
- php: >=8.1
- ext-json: *
- ext-mbstring: *
- ext-pcre: *
Requires (Dev)
- dealerdirect/phpcodesniffer-composer-installer: ^1.0
- phpcompatibility/php-compatibility: ^9.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5 || ^11.5
- rector/rector: ^2.0
- squizlabs/php_codesniffer: ^3.13
This package is auto-updated.
Last update: 2026-05-16 20:42:37 UTC
README
A standalone, pure-PHP engine that parses the OWASP Core Rule Set and evaluates HTTP requests against it. No FFI, no sidecars, no external runtimes — just PHP.
It exists so that a PHP-based firewall (or any PHP application) can speak the same rule format as ModSecurity / Coraza / CRS without shelling out, embedding a Go binary, or hand-translating thousands of regexes.
This package is a sibling of kanopi/firewall and is consumed by it through
a thin plugin adapter — but the engine itself depends on nothing in the
firewall and can be used standalone in any framework (Symfony, Laravel,
Drupal, WordPress, raw PHP).
Table of contents
- How it works
- Requirements
- Installation
- Quick start
- Configuration
- The request DTO
- The verdict
- Supported SecLang subset
- Rule scope
- Refreshing CRS rules
- Debugging a rule
- Testing
- Code quality checks
- CircleCI pipeline
- Project layout
- Versioning
- License and attribution
How it works
The engine has three stages:
-
Refresh (build time) —
bin/refresh-crsdownloads a pinned CRS release from GitHub, parses everyREQUEST-*.conffile with the bundled SecLang parser, and writes the result torules/:rules/<source>.json— one human-reviewable JSON file per CRS source file, used to make diff review of CRS bumps painless.rules/compiled.php— a singlevar_export'd PHP array that opcache can preload, used as the runtime hot path.rules/manifest.json— version, rule counts, parser warnings.
-
Load (process start) —
CrsEngine's constructor readsrules/compiled.phponce. With opcache enabled, subsequent processes hit a warm cache and pay almost no cost. -
Evaluate (per request) — the application adapts its framework request into a
RequestDataDTO and calls$engine->evaluate($request). The evaluator resolves CRS target expressions against the request, applies transforms, runs operators (mostly@rx), accumulates per-category anomaly scores, and returns aCrsVerdictcarrying the action (allow/log/block), matched rules, and scores.
The refresh step runs on a schedule in CI — never on production hot paths.
Requirements
- PHP 8.1 or higher (no upper bound; tested in CI against 8.1, 8.2, 8.3)
ext-json,ext-mbstring,ext-pcre(all bundled with standard PHP builds)- Composer
The package has no runtime composer dependencies — only phpunit,
phpstan, rector, and php_codesniffer for development.
Installation
composer require kanopi/crs-engine
After installing, generate the rule cache once:
vendor/bin/refresh-crs
This downloads the CRS release pinned in the package's .crs-version and
populates vendor/kanopi/crs-engine/rules/. You only need to do this on
fresh installs or when the pinned tag changes.
Quick start
use Kanopi\Crs\CrsConfig; use Kanopi\Crs\CrsEngine; use Kanopi\Crs\Request\RequestData; $engine = new CrsEngine(new CrsConfig( paranoia: 1, mode: CrsConfig::MODE_BLOCK, )); $request = new RequestData( method: 'GET', uri: '/login?user=admin&pw=' . rawurlencode("' OR 1=1"), rawUri: $_SERVER['REQUEST_URI'] ?? '/', queryString: $_SERVER['QUERY_STRING'] ?? '', protocol: 'HTTP/1.1', remoteAddr: $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0', queryArgs: $_GET, postArgs: $_POST, cookies: $_COOKIE, headers: getallheaders() ?: [], ); $verdict = $engine->evaluate($request); if ($verdict->isBlocked()) { http_response_code(403); error_log(sprintf( 'CRS blocked request: rule %d (%s)', $verdict->blockingRuleId, $verdict->matchedRules[0]['msg'] ?? '', )); exit; }
For framework-specific adapters (Symfony Request, PSR-7, Laravel
Illuminate\Http\Request, Drupal Symfony\HttpFoundation\Request), write a
small mapper that produces RequestData. There is intentionally no built-in
adapter — keeping the engine framework-free is the point.
Configuration
All configuration is constructor arguments on CrsConfig. Build it directly
or with CrsConfig::fromArray() if you load config from YAML / env.
new CrsConfig( paranoia: 1, // 1 (default) - 4. Higher = more strict, more false positives. mode: CrsConfig::MODE_BLOCK, // or MODE_MONITOR (records matches, never blocks) anomalyThresholds: [ 'critical' => 5, // total score >= threshold triggers block 'error' => 4, 'warning' => 3, 'notice' => 2, ], disabledRules: [920300, 942130], // skip these rule IDs disabledCategories: ['session_fixation'], // skip whole categories rulesPath: null, // override location of compiled.php );
| Field | Default | Notes |
|---|---|---|
paranoia |
1 |
Rules tagged with paranoia-level/N above this are skipped. |
mode |
block |
monitor evaluates and records matches but never returns block. |
anomalyThresholds |
severity-keyed | The critical value is compared against accumulated inbound_anomaly_score_plN totals. |
disabledRules |
[] |
List of CRS rule IDs to skip — useful for known false positives. |
disabledCategories |
[] |
Skip an entire category (sqli, xss, lfi, etc.) for targeted tuning. |
rulesPath |
bundled rules/ |
Point at a custom rule directory (used for testing and custom rulesets). |
The request DTO
Kanopi\Crs\Request\RequestData is the framework-agnostic input. Build it
once per request from whatever your framework provides:
new RequestData( method: 'POST', uri: '/api/comments', rawUri: '/api/comments', queryString: '', protocol: 'HTTP/1.1', remoteAddr: '203.0.113.42', queryArgs: $request->query->all(), // GET params postArgs: $request->request->all(), // POST/form params cookies: $request->cookies->all(), headers: $request->headers->all(), // name => string|string[] body: (string) $request->getContent(), files: [], // [{name, filename, mime, size}] );
RequestData::fromGlobals() is provided for CLI experimentation but should
not be used in framework integrations — your framework has a richer,
already-parsed request object.
The verdict
CrsEngine::evaluate() returns a Kanopi\Crs\CrsVerdict:
$verdict->action; // 'allow' | 'log' | 'block' $verdict->isBlocked(); // bool $verdict->blockingRuleId; // ?int — the first rule that fired with deny/block/drop $verdict->totalScore; // accumulated anomaly score across paranoia levels $verdict->scores; // per-category: ['sqli' => 5, 'xss' => 0, ...] $verdict->matchedRules; // array of [id, msg, severity, score, tags, category, matched_data] $verdict->toArray(); // serialisable shape for logging
In monitor mode action is log whenever any rule matched and allow
otherwise — isBlocked() always returns false. In block mode, the first
rule that asks to deny short-circuits evaluation.
Supported SecLang subset
The parser is deliberately narrower than full ModSecurity. It covers
everything CRS 4.x uses in its REQUEST-* rule files, with the explicit
exception of operators that need libinjection.
Directives: SecRule (full), SecAction/SecMarker (parsed and
ignored — used only as skipAfter targets).
Operators (16): @rx, @pm, @pmf, @beginsWith, @endsWith,
@contains, @containsWord, @streq, @eq/@gt/@lt/@ge/@le,
@within, @ipMatch (with CIDR), @validateByteRange,
@validateUrlEncoding, @validateUtf8Encoding.
Unsupported operators (@detectSQLi, @detectXSS, etc.) cause the rule
to be parsed-and-skipped with a warning recorded in manifest.json.
These two operators back CRS rules 942100 and 941100 specifically —
the libinjection-backed SQLi and XSS detectors. The other 50+ SQLi and 40+
XSS rules in CRS are pure @rx and work normally.
Transforms (20+): none, lowercase/uppercase,
urlDecode/urlDecodeUni, htmlEntityDecode,
compressWhitespace/removeWhitespace, replaceNulls/removeNulls,
utf8toUnicode, base64Decode/base64DecodeExt, cmdLine,
normalisePath, length, sha1/md5, trim,
removeComments/replaceComments.
Variables (targets): ARGS, ARGS_GET, ARGS_POST, ARGS_NAMES,
ARGS_GET_NAMES, ARGS_POST_NAMES, REQUEST_URI, REQUEST_URI_RAW,
REQUEST_FILENAME, REQUEST_METHOD, REQUEST_PROTOCOL, REQUEST_LINE,
REQUEST_BODY, REQUEST_HEADERS, REQUEST_HEADERS_NAMES,
REQUEST_COOKIES, REQUEST_COOKIES_NAMES, QUERY_STRING, REMOTE_ADDR,
FILES_NAMES, TX:<name>.
Target modifiers !collection:selector (exclude), &collection
(count), and regex selectors collection:/pattern/ are all supported.
Actions: id, phase, block/deny/drop/pass/allow, chain,
capture, multiMatch, t:*, msg, logdata, severity, tag,
ver/rev/maturity/accuracy (recorded but unused at runtime),
setvar, skipAfter. ctl:*, expirevar, deprecatevar, and similar
state-management actions are accepted by the parser and silently ignored.
Rule scope
Only request-side CRS rule files are parsed (response inspection is out of scope for v1):
REQUEST-911-METHOD-ENFORCEMENT
REQUEST-913-SCANNER-DETECTION
REQUEST-920-PROTOCOL-ENFORCEMENT
REQUEST-921-PROTOCOL-ATTACK
REQUEST-922-MULTIPART-ATTACK
REQUEST-930-APPLICATION-ATTACK-LFI
REQUEST-931-APPLICATION-ATTACK-RFI
REQUEST-932-APPLICATION-ATTACK-RCE
REQUEST-933-APPLICATION-ATTACK-PHP
REQUEST-934-APPLICATION-ATTACK-GENERIC
REQUEST-941-APPLICATION-ATTACK-XSS
REQUEST-942-APPLICATION-ATTACK-SQLI
REQUEST-943-APPLICATION-ATTACK-SESSION-FIXATION
REQUEST-944-APPLICATION-ATTACK-JAVA
REQUEST-949-BLOCKING-EVALUATION
RESPONSE-* files and CRS's own config/init files
(REQUEST-901-INITIALIZATION, REQUEST-905-COMMON-EXCEPTIONS,
RESPONSE-980-CORRELATION) are not parsed.
Refreshing CRS rules
bin/refresh-crs is the single entry point for keeping the rule cache
current. It is deliberately not run at runtime — only at build / CI time.
# Use the tag pinned in .crs-version bin/refresh-crs # Look up the latest stable CRS release on GitHub, update the pin, parse bin/refresh-crs --bump # Pin to a specific tag bin/refresh-crs --tag=v4.7.0 # Parse but don't overwrite rules/ bin/refresh-crs --dry-run
What it does:
- Reads
.crs-version(or applies the override flag). - Downloads
https://github.com/coreruleset/coreruleset/archive/refs/tags/<tag>.tar.gz. - Extracts to a temp directory with
PharData. - Parses every supported
REQUEST-*.confwith the bundledSecLangParser. - Writes
rules/<source>.json,rules/manifest.json, andrules/compiled.php. - Updates
.crs-versionwith the new tag.
The result is normal, reviewable git changes. The intended pattern for
production projects is a scheduled CI job that runs --bump weekly, opens a
PR with the regenerated rules, and lets a maintainer review the diff before
merging. CircleCI's weekly-refresh workflow in this repo demonstrates that
pattern.
Version pin format
.crs-version is a plain key=value file:
tag=v4.0.0
sha=
source=https://github.com/coreruleset/coreruleset
The source field can point at a fork or mirror. sha is populated for
provenance but not enforced.
Debugging a rule
bin/crs-explain prints the parsed form of a CRS rule and optionally tests
a payload against it:
# Show how rule 942260 is parsed bin/crs-explain 942260 # Test a payload against it bin/crs-explain 942260 --payload="' UNION SELECT password FROM users"
Output includes the rule's targets, transforms, operator, message, tags, and whether your payload matches. Useful for diagnosing false positives without trawling through CRS source.
Testing
Two test suites, separated by speed and scope.
# Everything (54 tests, fast) composer test # Just the unit tests composer test:unit # Just the integration tests composer test:integration # With coverage XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html coverage/
Unit tests (tests/Unit/) cover the parser, transforms, operators, and
the TxStore against hand-crafted SecLang snippets — no network, no real CRS
download.
Integration tests (tests/Integration/) run real-shaped CRS rules from
bundled fixtures (tests/Integration/fixtures/REQUEST-*.conf) against
real attack payloads:
- SQLi:
UNION SELECT,' OR 1=1,DELETE FROM, URL-encoded variants, monitor-mode behavior, disabled-rule behavior. - XSS:
<script>tags,javascript:URIs, event handlers, HTML-entity- encoded payloads. - Refresh flow: parse → write → load round-trip.
The fixture files mirror the format and identifier ranges of real CRS rules, so the integration tests double as regression checks for the parser.
Code quality checks
All three static analysis tools are wired into composer scripts and CI:
# Individual checks composer check:code # PHPCS (PSR-12 + PHPCompatibility for PHP 8.1+) composer check:stan # PHPStan at level max composer check:rector # Rector --dry-run # All checks at once composer check # Auto-fix what's mechanically fixable composer fix # Rector + PHPCBF in order
PHPStan runs at level: max. The full firewall library's pragmatic
identifier ignores (argument.type, cast.string, missingType.iterableValue)
are inherited so untrusted-JSON ingestion paths stay tractable.
Rector targets LevelSetList::UP_TO_PHP_81 so the engine stays compatible
with the lower PHP bound while still picking up modern idioms (readonly
properties, constructor promotion, etc.).
CircleCI pipeline
.circleci/config.yml mirrors kanopi/firewall — it uses the
kanopi/ci-tools@2 orb, runs every check on a PHP-version matrix
(8.1, 8.2, 8.3, 8.4, 8.5), and exposes three workflows.
| Workflow | Trigger | Jobs (each runs across the full PHP matrix) |
|---|---|---|
test |
every push / PR | phpunit, phpstan (via check:stan:circleci), rector (check:rector:circleci), quality (check:code:circleci) |
weekly-refresh |
scheduled trigger with pipeline-trigger=weekly-refresh |
refresh-and-branch (runs refresh-crs --bump, all static checks, all tests, then pushes a chore/crs-refresh-<tag>-<date> branch) |
release |
tag push matching vX.Y.Z |
Same matrix as test, but gated to tag pushes — green on every PHP version is the prerequisite for the release tag to ship. |
Each matrix job publishes JUnit results and stores the per-tool reports
(phpcs-report.xml, phpstan-report.xml, rector-report.xml,
reports/junit.xml) as CircleCI artifacts.
To wire up the weekly bump:
- In CircleCI's project settings, add a Scheduled Pipeline:
- Schedule:
0 6 * * 1(Mondays 06:00 UTC) - Pipeline parameter:
pipeline-trigger=weekly-refresh
- Schedule:
- Ensure the
kanopi-codeCircleCI context is attached. It carries the shared deploy SSH key (consumed byci-tools/copy-ssh-key), the GitHub token forgh pr create, and the Docker Hub credentials.
The job:
- Runs
bin/refresh-crs --bumpto fetch the latest upstream CRS release. - Re-runs phpcs / phpstan / rector / phpunit against the refreshed ruleset.
- If anything changed in
.crs-versionorrules/, commits to a newchore/crs-refresh-<tag>-<date>branch. - Pushes the branch and opens a PR automatically via
gh pr create, labelledcrs-bump, with rule counts and warning counts in the body.
A maintainer reviews the diff and merges — the only human step.
Project layout
crs-engine/
├── bin/
│ ├── refresh-crs Download + parse + write CRS rules
│ └── crs-explain Debug a parsed rule against a payload
├── rules/ Generated by refresh-crs (gitignored or committed per project policy)
│ ├── compiled.php Runtime hot path (var_export'd)
│ ├── manifest.json Version, counts, parser warnings
│ └── REQUEST-*.json Per-source-file JSON for review
├── src/
│ ├── CrsEngine.php Public entry point
│ ├── CrsConfig.php
│ ├── CrsVerdict.php
│ ├── Exception/
│ ├── Operators/ 16 SecLang operators + registry
│ ├── Parser/ SecLang parser + DTOs
│ ├── Refresh/ CrsFetcher, RuleWriter, VersionPin, RefreshRunner
│ ├── Request/RequestData.php Framework-agnostic request DTO
│ ├── Runtime/ RuleEvaluator, RuleSet, TxStore, TransformPipeline
│ ├── Transforms/ 20+ SecLang transforms + registry
│ └── Variables/ VariableResolver (ARGS, REQUEST_HEADERS, etc.)
├── tests/
│ ├── Integration/
│ │ ├── fixtures/ CRS-shaped .conf files used by integration tests
│ │ ├── RefreshFlowTest.php
│ │ ├── SqliRulesTest.php
│ │ └── XssRulesTest.php
│ └── Unit/
│ ├── Operators/
│ ├── Parser/
│ ├── Runtime/
│ └── Transforms/
├── .circleci/config.yml
├── .crs-version Pinned upstream CRS tag
├── composer.json
├── phpcs_ruleset.xml PSR-12 + PHPCompatibility 8.1+
├── phpstan.neon level: max
└── rector.php UP_TO_PHP_81 + standard set list
Versioning
The engine follows semver, with CRS pin bumps driving the change type:
- Patch (
v0.1.1→v0.1.2): engine bug fix, no parser/runtime API change, no CRS bump. - Minor (
v0.1.x→v0.2.0): CRS bump (any), new operators or transforms, parser improvements that are strictly additive. - Major (
v0.x→v1.0): breaking change toCrsEngine/CrsConfig/CrsVerdict/RequestDatapublic API.
Each release commit carries the CRS tag it ships with in its message, e.g.
Release v0.3.0 (CRS v4.7.0). The shipped .crs-version is authoritative.
License and attribution
The engine code is licensed under the MIT License (see
composer.json).
CRS rule content under rules/ is a derived work of the
OWASP Core Rule Set, which is
licensed under Apache 2.0. The CRS NOTICE and LICENSE files are copied
into rules/ on every refresh; please retain them when redistributing.
This package does not vendor or republish CRS — it downloads it on demand during the refresh step.