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).
- 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
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.
- 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.
composer require kanopi/crs-engineAfter installing, generate the rule cache once:
vendor/bin/refresh-crsThis 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.
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.
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. |
Use named arguments, or fromArray(). The constructor takes fourteen
parameters and will gain more; positional construction is not a supported way
to call it, and the parameter order carries no meaning worth relying on.
fromArray() is the path for CMS integrations — a Drupal module hands it
config.get(), a WordPress plugin hands it get_option() — and every
constructor parameter has a snake_case key there. That parity is enforced by
CrsConfigArrayParityTest, so a parameter added later cannot quietly become
unreachable from configuration.
Unknown keys are ignored rather than rejected, because handing over a whole settings array that carries unrelated keys is the normal shape. The cost is that a mistyped key does nothing silently, so check against the table above.
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.
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.
Two integration details change how much the engine can see. Neither is obvious, and both silently reduce detection if missed.
Which body formats reach the rules. The ruleset looks at ARGS — 187 rules
target it, against 19 that read REQUEST_BODY as raw text — so a structured
body has to reach ARGS to be inspected properly.
| Content-Type | Reaches ARGS |
Parsed by |
|---|---|---|
application/x-www-form-urlencoded |
yes | engine, if postArgs is empty |
application/json (and +json types) |
yes, as json.path names |
engine, if postArgs is empty |
text/xml, application/xml |
yes, via XML: targets |
engine, always |
multipart/form-data |
only if you supply postArgs |
you |
text/plain, application/octet-stream |
n/a — no structure | — |
Every body is read as raw text through REQUEST_BODY regardless, which is the
right treatment for the unstructured ones.
Multipart is yours to supply. Boundaries, part headers, transfer encodings
and file parts add up to a real parser, and it is the one format where
disagreeing subtly with your application creates bypasses rather than closing
them — so the engine does not guess. Pass postArgs, and files,
multipartFlags and multipartPartHeaders if your parser can produce them; the
CRS 922 rules read those. If a multipart body arrives with no postArgs, the
verdict reports multipart_body_unparsed rather than passing quietly.
Supplying postArgs is better even where the engine can parse. Not for
detection — both give the same verdict, which is asserted per payload — but
because it removes a parser differential. If the engine parses the body and your
application parses it differently, an attacker can arrange for the two to
disagree and get the engine inspecting something the application never sees.
Your decode is the one the application will act on, so it is the one worth
inspecting:
$decoded = json_decode($rawBody, true);
new RequestData(
// ...
postArgs: is_array($decoded) ? $decoded : [],
body: $rawBody,
);postArgs wins outright whenever it is populated; the engine parses only when
nothing did.
Gaps are reported, not silent. CrsVerdict::$truncations records
body_too_large_to_parse, json_body_unparsable and multipart_body_unparsed,
so "the WAF is not catching anything" is diagnosable rather than guesswork.
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.
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.
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.
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 loggingIn 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.
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 (26): none, lowercase/uppercase,
urlDecode/urlDecodeUni, htmlEntityDecode,
compressWhitespace/removeWhitespace, replaceNulls/removeNulls,
utf8toUnicode, base64Decode/base64DecodeExt, cmdLine,
normalizePath (aliased as normalisePath), normalizePathWin,
jsDecode, cssDecode, escapeSeqDecode,
length, sha1/md5, trim,
removeComments/replaceComments/removeCommentsChar.
The decoding transforms are anti-evasion steps, and every one of them was
missing until recently — 76 occurrences across 52 rules were being skipped, so
those rules matched against less-normalised input than upstream intends. The
sharpest case was normalizePath: the engine implemented it under the British
spelling, which nothing in CRS writes, so it was dead code and the 930 LFI
series ran unnormalised. Both spellings resolve now.
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.
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.
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.
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.
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.
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.
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-runWhat 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.
.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.
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.
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.
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 orderPHPStan 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/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.
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
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.
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.