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-07-28 05:10:57 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 categories
- Detection coverage
- 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-*.confandRESPONSE-*.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; // Construct once per process — loading the ruleset is the expensive part. $engine = new CrsEngine(new CrsConfig( paranoia: 1, mode: CrsConfig::MODE_BLOCK, )); $verdict = $engine->evaluate(RequestData::fromGlobals()); if ($verdict->isBlocked()) { http_response_code(403); error_log(sprintf( 'CRS blocked request: %s, score %d, %d rule(s) matched', $verdict->blockingRuleId === null ? 'anomaly threshold reached' : 'rule ' . $verdict->blockingRuleId, $verdict->totalScore, count($verdict->matchedRules), )); exit; }
fromGlobals() is for plain PHP and quick experiments. Behind a framework,
build the DTO from its request object instead:
$request = new RequestData( method: $r->getMethod(), uri: $r->getRequestUri(), rawUri: $r->server->get('REQUEST_URI', '/'), queryString: $r->server->get('QUERY_STRING', ''), protocol: $r->server->get('SERVER_PROTOCOL', 'HTTP/1.1'), remoteAddr: $r->getClientIp() ?? '0.0.0.0', queryArgs: $r->query->all(), postArgs: $r->request->all(), cookies: $r->cookies->all(), headers: array_map(static fn (array $v): string => $v[0], $r->headers->all()), body: (string) $r->getContent(), );
Include
Content-Lengthon requests that have a body. CRS rule 920180 treats a POST carrying neitherContent-LengthnorTransfer-Encodingas request smuggling and will score it.fromGlobals()handles this; a hand-built DTO has to supply it.
There is intentionally no built-in framework adapter — keeping the engine framework-free is the point. Adapters for Symfony, PSR-7, Laravel and Drupal are a few lines each, as above.
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: [ 'inbound' => 5, // request score >= this blocks the request 'outbound' => 4, // response score >= this blocks the response ], disabledRules: [920300, 942130], // skip these rule IDs disabledCategories: ['session_fixation'], // skip whole categories rulesPath: null, // override location of compiled.php severityScores: [ 'critical' => 5, // what each severity *adds* to the score 'error' => 4, 'warning' => 3, 'notice' => 2, ], maxRequestBodyBytes: 131072, // request body bytes handed to the ruleset maxResponseBodyBytes: 524288, // response body bytes — larger, see below maxArgs: 255, // argument values inspected (counting is uncapped) maxArgBytes: 131072, // total argument bytes inspected per rule responseMode: null, // overrides `mode` outbound only );
| 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 |
['inbound' => 5, 'outbound' => 4] |
Score at which to block, per direction. There are exactly two thresholds. |
disabledRules |
[] |
List of CRS rule IDs to skip — useful for known false positives. |
disabledCategories |
[] |
Skip an entire category for targeted tuning — see Rule categories. |
rulesPath |
bundled rules/ |
Point at a custom rule directory (used for testing and custom rulesets). |
severityScores |
CRS defaults | Anomaly contribution per severity. Upstream exposes these in crs-setup.conf. |
requestMode / responseMode |
follow mode |
Run the two directions in different modes — see Request and response are configured separately. |
maxRequestBodyBytes |
131072 |
Request body bytes inspected. CrsConfig::UNLIMITED to disable. |
maxResponseBodyBytes |
524288 |
Response body bytes inspected. Deliberately larger than the request limit. |
maxArgs |
255 |
Argument values inspected. Counting is never capped, so &ARGS rules still see the true total. |
maxArgBytes |
131072 |
Total argument bytes a single rule inspects. Bounds what a few very large arguments cost. |
failClosedOnOperatorError |
false |
Treat a request whose evaluation hit an operator error as blocked. |
Request and response are configured separately
The two directions do not carry the same traffic and do not warrant the same handling, so the knobs that can differ, do:
new CrsConfig( mode: CrsConfig::MODE_BLOCK, // reject attacks on the way in responseMode: CrsConfig::MODE_MONITOR, // only record leakage on the way out );
That pairing is the usual posture for a CMS. Blocking outbound is a far heavier
action than blocking inbound: the application has already done its work, and
rejecting the response means serving an error in place of a page that is very
likely fine. monitor outbound still evaluates every RESPONSE-* rule and
still populates matchedRules and totalScore — it just never returns
block.
The body limits are split for the same reason. maxRequestBodyBytes defaults
to 131072, matching ModSecurity's SecRequestBodyNoFilesLimit;
maxResponseBodyBytes defaults to 524288, matching SecResponseBodyLimit. A
128 KB request body is large, whereas a 128 KB HTML page is ordinary — and
leaked stack traces and SQL errors tend to appear in the tail of a page, so a
request-sized cap applied outbound hid exactly the evidence the response rules
exist to find.
Whenever a limit does engage, CrsVerdict::$truncations records what was
inspected against what arrived, so reduced coverage is visible rather than
inferred.
anomalyThresholds was already directional and is unchanged. maxArgs and
maxArgBytes are request-only concepts, since a response has no arguments.
Tuning for a CMS
CRS is tuned for applications, and a CMS is an application whose users paste code into text fields. At PL1 the shipped ruleset flags a fair amount of ordinary editorial content, and that is upstream behaviour rather than an engine defect — but it is the first thing you will hit, so it is worth knowing what to expect before you turn blocking on.
Sweeping twenty realistic benign requests through the default config, four were blocked:
| Traffic | Rules | Why |
|---|---|---|
A post body containing cat /etc/hosts | grep localhost |
930120, 932235, 932260 | Shell commands in content look exactly like RCE payloads |
| A regex in a support ticket | 932280 | Shell metacharacters |
| A JSON blob pasted into a form field | 920540 | \uXXXX reads as a Unicode bypass outside a JSON body |
A URL path containing ../ |
930100, 930110 | Genuinely a traversal pattern |
None of these have a clever fix. They need exclusions, scoped as tightly as you can manage.
Start here, then narrow. Exclude the specific fields that carry authored content rather than disabling rules globally:
new CrsConfig( // Fields where users legitimately paste code, paths and regexes. // Prefer this over disabledRules: it keeps the rule working everywhere else. disabledRules: [ 932235, 932260, // unix command injection — trips on shell examples 932280, // shell metacharacters — trips on regexes ], );
If only part of your site accepts authored content, run two engine instances with different configs and pick per route. That keeps full strength on your login and checkout paths, where it matters most, and relaxes only the editor.
Watch before you block. mode: MODE_MONITOR evaluates everything and
returns log instead of block. Run it over real traffic for a week, group
matchedRules by rule id, and you will have a far better exclusion list than
any generic one — including this one.
Outbound is already monitor-only by default. See
Request and response are configured separately;
you do not need to do anything to avoid a docs page being blocked for
mentioning fopen.
Feeding the engine correctly
Two integration details change how much the engine can see. Neither is obvious, and both silently reduce detection if missed.
Decode JSON bodies into postArgs. The engine does not parse request
bodies — that is the integrator's job, because your framework has already done
it. REQUEST_BODY is inspected as a single string, but almost all of the
detection rules target ARGS, so an attack in an undecoded JSON body is not
seen:
// A JSON API endpoint. Without the decode, ARGS is empty. $decoded = json_decode($rawBody, true); new RequestData( // ... postArgs: is_array($decoded) ? $decoded : [], body: $rawBody, );
SQLi in a JSON body, not decoded allow score=0
SQLi in a JSON body, decoded block score=10 rules=[942190,942360,949110]
Set bodyProcessor, or send an accurate Content-Type. CRS gates real
behaviour on REQBODY_PROCESSOR: rule 920539 checks it for JSON and switches
off 920540, which would otherwise flag every \uXXXX escape as a Unicode
bypass. The engine infers the processor from Content-Type when you do not set
it explicitly, so an accurate header is usually enough.
Thresholds vs. severity scores
These are the two halves of the anomaly model and are easy to confuse:
severityScoresis what a matching rule adds. Aseverity:CRITICALrule contributes 5 by default.anomalyThresholdsis what the running total is compared against. Cross the inbound threshold and CRS rule 949110 blocks the request; cross the outbound one and 959100 blocks the response.
So the defaults mean "block a request once it accumulates one critical-severity
detection". Raise inbound to 10 to require two.
Deprecated:
anomalyThresholdspreviously took severity keys, wherecriticalsilently meant inbound anderrormeant outbound, whilewarningandnoticedid nothing at all. Those spellings still work and emitE_USER_DEPRECATED;warning/noticeare ignored. Unrecognised keys now throwConfigurationExceptioninstead of being silently accepted.
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; // [id, msg, severity, score, tags, category, matched_data, logdata] $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 (unconditional — CRS uses it to
reset aggregate scores between phases, so it is evaluated, not ignored), and
SecMarker (a placeholder that keeps its position so skipAfter has
somewhere to land).
Operators (18): @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. See
Detection coverage for what that costs in practice
and what the engine does about it.
Transforms (21): none, lowercase/uppercase,
urlDecode/urlDecodeUni, htmlEntityDecode,
compressWhitespace/removeWhitespace, replaceNulls/removeNulls,
utf8toUnicode, base64Decode/base64DecodeExt, cmdLine,
normalisePath, length, sha1/md5, trim,
removeComments/replaceComments.
Variables (targets), 52 in total:
Request — ARGS, ARGS_GET, ARGS_POST, ARGS_NAMES, ARGS_GET_NAMES,
ARGS_POST_NAMES, REQUEST_URI, REQUEST_URI_RAW, REQUEST_FILENAME,
REQUEST_BASENAME, REQUEST_METHOD, REQUEST_PROTOCOL, REQUEST_LINE,
REQUEST_BODY, REQUEST_HEADERS, REQUEST_HEADERS_NAMES, REQUEST_COOKIES,
REQUEST_COOKIES_NAMES, QUERY_STRING, REMOTE_ADDR, UNIQUE_ID,
REQBODY_PROCESSOR.
Uploads — FILES, FILES_NAMES, FILES_SIZES, FILES_TMPNAMES. FILES
reads the upload's tmp_name when readable, or inline content if the
integrator pre-read it.
Response, available to evaluateResponse() — RESPONSE_STATUS,
RESPONSE_PROTOCOL, RESPONSE_HEADERS, RESPONSE_HEADERS_NAMES,
RESPONSE_BODY, RESPONSE_CONTENT_TYPE, RESPONSE_CONTENT_LENGTH,
OUTBOUND_DATA_ERROR.
XML — XML with an XPath selector, e.g. XML:/*. Parsed lazily, once per
request. External entities never resolve; see
XmlBody.
Multipart — MULTIPART_PART_HEADERS, plus the CRS-922 anti-evasion flags
MULTIPART_STRICT_ERROR, MULTIPART_UNMATCHED_BOUNDARY,
MULTIPART_BOUNDARY_QUOTED, MULTIPART_BOUNDARY_WHITESPACE,
MULTIPART_CRLF_LF_LINES, MULTIPART_DATA_AFTER, MULTIPART_DATA_BEFORE,
MULTIPART_FILE_LIMIT_EXCEEDED, MULTIPART_HEADER_FOLDING,
MULTIPART_INVALID_HEADER_FOLDING, MULTIPART_INVALID_PART,
MULTIPART_INVALID_QUOTING, MULTIPART_LF_LINE,
MULTIPART_MISSING_SEMICOLON, MULTIPART_NAME, MULTIPART_SEMICOLON_MISSING.
These are only populated if the integrator supplies them on RequestData —
the engine does not parse multipart bodies itself.
Engine state — TX:<name>, including TX:/regex/ to read a family of
names such as the per-parameter keys setvar generates.
Target modifiers !collection:selector (exclude), &collection
(count), and regex selectors collection:/pattern/ are all supported.
Actions: id, phase, chain, msg, severity, tag, t:*,
multiMatch, skipAfter, and:
setvar—%{...}on both sides is expanded per request, sotx.counter_%{MATCHED_VAR_NAME}produces one key per parameter.capture— numbered regex groups are written toTX:0–TX:9before any chained condition runs, which is where CRS reads them.logdata— expanded per request and returned onCrsVerdict::$matchedRules[]['logdata'].block/deny/drop/pass/allow— onlydenyanddropstop evaluation.blockdefers toSecDefaultAction, which for CRS detection rules means "score and continue"; see Detection coverage.
ver/rev/maturity/accuracy are parsed and unused. ctl:*, expirevar,
deprecatevar, initcol and similar state management are accepted and
ignored.
Not implemented: a rule's actions fire once per rule, not once per matching variable. CRS's per-parameter counters (921170/921180, HTTP parameter pollution) need the latter and do not work.
Rule categories
Each rule takes its category from the CRS file it came from. Categories drive
disabledCategories and the per-category breakdown in CrsVerdict::$scores.
Request side
| Category | Source | Rules |
|---|---|---|
method_enforcement |
REQUEST-911 | 9 |
scanner |
REQUEST-913 | 9 |
protocol_enforcement |
REQUEST-920 | 68 |
protocol_attack |
REQUEST-921 | 26 |
multipart |
REQUEST-922 | 6 |
lfi |
REQUEST-930 | 14 |
rfi |
REQUEST-931 | 13 |
rce |
REQUEST-932 | 55 |
php |
REQUEST-933 | 29 |
generic |
REQUEST-934 | 20 |
xss |
REQUEST-941 | 40 |
sqli |
REQUEST-942 + supplemental/ |
67 |
session_fixation |
REQUEST-943 | 11 |
java |
REQUEST-944 | 22 |
Response side
| Category | Source | Rules |
|---|---|---|
response_leak |
RESPONSE-950 | 14 |
response_leak_sql |
RESPONSE-951 | 26 |
response_leak_java |
RESPONSE-952 | 10 |
response_leak_php |
RESPONSE-953 | 13 |
response_leak_iis |
RESPONSE-954 | 14 |
web_shell |
RESPONSE-955 | 36 |
response_leak_ruby |
RESPONSE-956 | 11 |
Both directions — blocking_evaluation (REQUEST-949 + RESPONSE-959, 56)
applies the anomaly threshold; disabling it turns off score-based blocking
entirely. correlation (RESPONSE-980, 21) is logging only.
Changed: categories used to be derived by matching words in the filename, which collided.
rcealso covered method and protocol enforcement, because "enforcement" contains "rce";phpandjavaalso covered their response-leak counterparts; and everything unrecognised fell into amiscbucket. If you disable categories, re-check your list:miscno longer exists,rceis now REQUEST-932 only, andprotocol_enforcement,method_enforcement,blocking_evaluation,response_leak_java,response_leak_phpandresponse_leak_rubyare new names for rules that were previously filed elsewhere.
Detection coverage
The engine cannot run CRS 942100 and 941100, the two libinjection-backed detectors, and those are the PL1 backbone for SQL injection. Measured against 34 attack payloads and 32 samples of realistic CMS/search traffic:
| Paranoia | Attacks blocked | False positives |
|---|---|---|
| 1 (default) | 30/34 (88%) | 0/32 (0%) |
| 2 | 34/34 (100%) | 5/32 (15.6%) |
| 3 | 34/34 (100%) | 10/32 (31%) |
| 4 | 34/34 (100%) | 27/32 (84%) |
Raising the default to PL2 is not recommended. The rules that come in at
PL2 block ordinary content — markdown post bodies, JSON API payloads, code
snippets in comment fields, quoted prose. PL3 additionally blocks accented,
CJK and Arabic names. Both are usable, but only with a per-site exclusion
list built from real traffic; see disabledRules and disabledCategories.
What the engine adds
supplemental/REQUEST-948-TAUTOLOGY.conf ships one engine-owned rule,
948100, covering the ' OR '1'='1 / OR 1=1 family that libinjection
would otherwise catch at PL1. It is parsed alongside CRS by bin/refresh-crs
and survives CRS bumps because it lives outside rules/. It is tagged
kanopi-crs-engine so it is easy to tell apart from upstream rules, and it
can be turned off like any other rule:
new CrsConfig(disabledRules: [948100]);
Its false-positive profile is pinned by tests/Integration/PayloadCorpusTest.php,
which fails the build if it starts flagging ordinary prose.
Known gaps at PL1
These need real tokenisation and are deliberately not chased with regex, because every pattern that catches them also catches ordinary content:
| Payload | Why not | Caught at |
|---|---|---|
admin'-- |
collides with quoted prose using -- |
PL2 |
`id` |
collides with inline code in comment fields | PL2 |
If you need these at PL1, run PL2 with an exclusion list, or open an issue about porting libinjection.
Rule scope
Every REQUEST-*.conf and RESPONSE-*.conf in the pinned CRS release is
parsed, plus this engine's own supplemental/:
REQUEST-911-METHOD-ENFORCEMENT RESPONSE-950-DATA-LEAKAGES
REQUEST-913-SCANNER-DETECTION RESPONSE-951-DATA-LEAKAGES-SQL
REQUEST-920-PROTOCOL-ENFORCEMENT RESPONSE-952-DATA-LEAKAGES-JAVA
REQUEST-921-PROTOCOL-ATTACK RESPONSE-953-DATA-LEAKAGES-PHP
REQUEST-922-MULTIPART-ATTACK RESPONSE-954-DATA-LEAKAGES-IIS
REQUEST-930-APPLICATION-ATTACK-LFI RESPONSE-955-WEB-SHELLS
REQUEST-931-APPLICATION-ATTACK-RFI RESPONSE-956-DATA-LEAKAGES-RUBY
REQUEST-932-APPLICATION-ATTACK-RCE RESPONSE-959-BLOCKING-EVALUATION
REQUEST-933-APPLICATION-ATTACK-PHP RESPONSE-980-CORRELATION
REQUEST-934-APPLICATION-ATTACK-GENERIC
REQUEST-941-APPLICATION-ATTACK-XSS supplemental/
REQUEST-942-APPLICATION-ATTACK-SQLI REQUEST-948-TAUTOLOGY
REQUEST-943-APPLICATION-ATTACK-SESSION-FIXATION
REQUEST-944-APPLICATION-ATTACK-JAVA
REQUEST-949-BLOCKING-EVALUATION
Request-phase rules (phase 1–2) run on evaluate(); response-phase rules
(phase 3–4) run on evaluateResponse(). Phase-5 logging rules are parsed but
contribute nothing to a verdict.
Two CRS files are deliberately not parsed:
REQUEST-901-INITIALIZATION and REQUEST-905-COMMON-EXCEPTIONS. They are
CRS's own configuration scaffolding, normally driven by crs-setup.conf,
which this engine replaces with CrsConfig and
CrsTxDefaults. Parsing 901 in particular
would make rule 901001 deny every request, because it checks a variable
crs-setup.conf is supposed to have set.
The current release parses 586 CRS rules plus 1 supplemental rule, 67
chain conditions, 6 SecAction directives and 29 SecMarker placeholders.
Four CRS rules are skipped for using @detectSQLi/@detectXSS; the counts
are asserted by tests/Integration/RulesetInvariantsTest.php, so they cannot
drift silently across a CRS bump.
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. - Verifies the content digest against the pin — unless
--bumpwas passed, which re-pins instead. A mismatch aborts before anything is parsed or written, leavingrules/untouched. - Parses every supported
REQUEST-*.confwith the bundledSecLangParser, plus anything insupplemental/. - Builds the output in a staging directory and swaps it into place only once
every file has been written. A failure part-way through leaves the previous
rules/intact and loadable. - Updates
.crs-versionwith the tag and the digest.
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.
The automated path performs no verification. Because that job runs
--bump, and--bumpre-pins rather than checks, the weekly refresh is trust-on-first-use every week — it downloads whatever the source serves, hashes it, records the hash as the new pin, and compares against nothing. That is unavoidable for a version bump, but it means human review of therules/diff is the only control on what enters the ruleset. The PR the workflow opens says so explicitly rather than leaving a reviewer to assume the green checkmarks covered it; they cover parsing and tests, not provenance. See Version pin format for what the digest does and does not establish.
Version pin format
.crs-version is a plain key=value file:
tag=v4.28.0
sha=sha256:aac29fbd56288cb37adec9ac879fa694f6aaa410a564bf5b834bd335d0e216df
source=https://github.com/coreruleset/coreruleset
The source field can point at a fork or mirror.
sha is a content digest of the CRS rule files — a sha256 over
filename:sha256 for every file in the release's rules/ directory, sorted.
It is enforced on a plain bin/refresh-crs: if the rules published under the
pinned tag stop matching it, the refresh fails and rules/ is left alone. It
covers top-level files only, and a subdirectory in the upstream tree aborts the
refresh rather than being silently left outside the digest.
It hashes the extracted files rather than the tarball on purpose. GitHub's
/archive/refs/tags/ tarballs are generated on demand and are not guaranteed
byte-stable — when GitHub changed its gzip in 2023, every auto-generated
archive checksum changed at once and broke everyone pinning them. Since the
refresh runs on a schedule, a pin that can fail for reasons unrelated to the
content would just train people to ignore it.
Be clear about what this does and does not buy you. It detects ruleset
substitution: a re-tagged release, a tampered mirror, an unexpected content
change under a tag you have already reviewed. It is not a signature — it
does not authenticate the publisher, and it cannot help on the very first
fetch, which is trust-on-first-use. If the pin is empty the digest is recorded
rather than enforced, and bin/refresh-crs says so:
Content digest recorded (nothing to verify against yet): sha256:9987…
Content digest verified: sha256:9987…
--bump deliberately re-pins, because moving to a new tag is a content change
by definition. Review the rules/ diff in the resulting PR — that diff, not
the digest, is what tells you what actually changed.
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 — unit + integration, no network, about a second 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
├── supplemental/ Engine-owned SecLang, parsed alongside CRS and
│ not clobbered by a refresh — see Detection coverage
├── src/
│ ├── CrsEngine.php Public entry point
│ ├── CrsConfig.php
│ ├── CrsVerdict.php
│ ├── Exception/
│ ├── Body/XmlBody.php Lazy XML body parser for XML: targets
│ ├── Operators/ 18 SecLang operators + registry + PhraseSet
│ ├── Parser/ SecLang parser + DTOs
│ ├── Refresh/ CrsFetcher, RuleWriter, VersionPin, RulesetDigest,
│ │ RefreshRunner, CrsSource
│ ├── Request/ RequestData + ResponseData DTOs
│ ├── Runtime/ RuleEvaluator, RuleSet, TxStore, TransformPipeline,
│ │ CrsTxDefaults, CompiledRule
│ ├── Transforms/ 21 SecLang transforms + registry
│ └── Variables/ VariableResolver (52 targets)
├── tests/
│ ├── Integration/ Against the real bundled ruleset:
│ │ ├── fixtures/ CRS-shaped .conf files for the narrower tests
│ │ ├── PayloadCorpusTest.php detection rate + false positives
│ │ ├── RulesetInvariantsTest.php rule counts, markers, TX contract
│ │ ├── DisabledCategoryScopeTest.php
│ │ └── RefreshFailureModesTest.php digest + atomic write
│ └── Unit/
│ ├── Body/
│ ├── Operators/
│ ├── Parser/
│ ├── Runtime/
│ ├── Transforms/
│ └── Variables/
├── .circleci/config.yml
├── .crs-version Pinned upstream CRS tag + content digest
├── 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.