From 61811512a8d97d72bad4fc72b96c964cd4d410b6 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:18:57 +0200 Subject: [PATCH] feat(facts): PHP semantic facts, validated against a real PHP CPG contributor-scan 5.1, the first step toward the v0.1 WordPress target. Written against a PHP CPG rather than against PHP source, because the two do not look alike. Verified with joern-parse 4.0.623 / php2cpg on a probe: $_GET["name"] -> call `.indexAccess`, CODE $_GET["name"] mysqli_query($conn, $sql) -> call NAMED mysqli_query echo $_GET["msg"] -> call NAMED echo So sources match on code and sinks match on name, and every entry the probe did not exercise is marked unverified in the file -- `include` and `require` among them, which is PHP's most characteristic path bug and the first thing to check on a real checkout. Five families derive (injection, output_encoding, path, deserialization, untrusted_destination) with no sink that is its own sanitizer -- asserted by a test, because that bug class has now appeared three times in three matchers. `php` is added to the region model's languages, without which the facts would have reached nothing. The arbiter decides 4 of 4 on the probe: the SQL concat entails, its `mysqli_real_escape_string` twin drops to corroborated, `echo` XSS entails and `system` command injection entails. **A whole scan finds 2 of those 3, and the gap is the finding.** PHP files yield FILE-LEVEL regions because no PHP entry points are mapped, and the taint arbiter returns one verdict per region per family -- so a file-level region reports at most one injection finding however many it holds. `system` won on flow length and the SQL injection went unreported. That is the mirror image of task 2.9: that one is many regions collapsing to one site, this is many sites collapsing to one verdict, and it bites hardest exactly where PHP needs it not to, since a WordPress file is large and has no decorator for the mapper to find. Recorded as task 5.4 with WordPress's registration model (add_action, add_filter, admin-ajax, REST routes), which the mapper already handles in principle for other frameworks. A 13-line probe is not a WordPress result. What is established is that the seam works end to end for PHP; everything about scale and coverage is unmeasured, and there are still zero PHP pairs in a corpus of 263 python, 351 c_cpp, 47 javascript and 49 java. Gates byte-identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG --- .kiro/specs/contributor-scan/tasks.md | 21 +++ .../measurements/2026-09-09-php-facts.json | 32 ++++ src/openultrasast/model/regions.py | 2 +- src/openultrasast/ruleset/semantic/php.toml | 158 ++++++++++++++++++ tests/test_model_specs.py | 43 +++++ 5 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 benchmarks/measurements/2026-09-09-php-facts.json create mode 100644 src/openultrasast/ruleset/semantic/php.toml diff --git a/.kiro/specs/contributor-scan/tasks.md b/.kiro/specs/contributor-scan/tasks.md index 9d67296..da87363 100644 --- a/.kiro/specs/contributor-scan/tasks.md +++ b/.kiro/specs/contributor-scan/tasks.md @@ -377,6 +377,10 @@ measurement that could produce a true positive. - [ ] 5.1 PHP/WordPress slice + - **Fact tables landed 2026-09-09** (`benchmarks/measurements/2026-09-09-php-facts.json`). Five families + derive, and the arbiter decides a real PHP CPG 4 of 4 on a probe: SQL concat entails, its escaped twin + drops to corroborated, `echo` XSS and `system` command injection entail. `php` added to the region + model's languages. What remains is below. - Plugin and core weaknesses. `php2cpg` needs a PHP interpreter, which the image provides. **Validate on a sample that the model can read the pairs before vendoring** — the OWASP harvest found 238 and vendored 40 for exactly this reason. @@ -404,6 +408,23 @@ - _Requirements: 9.2, 9.4, 9.5, 9.6_ - _Depends: 4.1_ +- [ ] 5.4 WordPress entry points, and the one-verdict-per-region limit + - Two things stand between the PHP facts and a WordPress result, and the second was found by running the + first. + - **Entry points.** WordPress registers handlers with `add_action`, `add_filter`, admin-ajax and REST + routes. Nothing maps them, so every PHP file becomes one file-level region. The mapper already handles + registration-based entry points for other frameworks (predecessor task 2.6), so this is a new recogniser + rather than a new mechanism. + - **One verdict per region per family.** The taint arbiter returns a single verdict for a region and + family, so a file-level region reports at most one injection finding however many the file holds: on the + probe, `system` won on flow length and the SQL injection went unreported. This is the mirror of 2.9 -- + that is many regions collapsing to one site, this is many sites collapsing to one verdict -- and it bites + hardest where PHP needs it not to. + - Observable: the probe reports 3 of 3, and a WordPress checkout yields function-scoped regions rather than + one region per file. + - _Requirements: 2.1, 8.1, 9.1_ + - _Depends: 5.1_ + ## Group 6 — Regression baselines - [ ] 6.1 Repository baseline and delta diff --git a/benchmarks/measurements/2026-09-09-php-facts.json b/benchmarks/measurements/2026-09-09-php-facts.json new file mode 100644 index 0000000..5c73594 --- /dev/null +++ b/benchmarks/measurements/2026-09-09-php-facts.json @@ -0,0 +1,32 @@ +{ + "arbiter_validation": { + "get_user": "model_entailed injection -- $_GET -> concat -> mysqli_query", + "run": "model_entailed injection -- system($_POST)", + "safe_user": "model_corroborated -- mysqli_real_escape_string seen on the path, so the rung drops", + "show": "model_entailed output_encoding -- echo $_GET" + }, + "caveat": "A 13-line probe is not a WordPress result. What is established is that the seam works end to end for PHP: php2cpg parses, the facts derive, the arbiter decides, and a scan reports at the right locations. Everything about scale and coverage is unmeasured.", + "generated": "2026-09-09", + "how_the_tables_were_derived": "Against a real PHP CPG rather than against PHP source, because the two do not look alike. Verified with joern-parse 4.0.623 / php2cpg: a superglobal is an `.indexAccess` whose CODE reads $_GET[\"name\"], while a dangerous operation is a plain call NAMED mysqli_query or echo. So sources match on code and sinks match on name, and every entry the probe did not exercise is marked unverified in the file.", + "reading": "PHP facts exist, derive five families, and decide a real PHP CPG correctly on a 13-line probe: the vulnerable SQL concat entails, its escaped twin drops to corroborated, XSS via echo entails and command injection via system entails. That is 4 of 4 by the arbiter and 2 of 3 through a whole scan, and the difference between those two numbers is the finding.", + "scan_validation": { + "entailed": 2, + "found": [ + "vuln.php:12:show", + "vuln.php:13:run" + ], + "missed": "vuln.php:6:get_user, the SQL injection", + "regions": 1 + }, + "spec": "contributor-scan 5.1 (Req 9.1) -- PHP fact tables, first step toward the v0.1 WordPress target", + "the_gap_the_scan_exposed": { + "detail": "PHP files yield FILE-LEVEL regions, because no PHP entry points are mapped yet. The taint arbiter returns ONE verdict per region per family, so a file-level region reports at most one injection finding however many it contains: `system` won on flow length and the SQL injection at line 6 went unreported. This is the mirror image of task 2.9 -- that one is many regions collapsing to one site, this is many sites collapsing to one verdict -- and it bites hardest exactly where PHP needs it not to, since a WordPress file is large and has no decorator for the mapper to find.", + "id": "one-verdict-per-region-per-family" + }, + "what_is_still_missing_for_wordpress": [ + "Entry-point mapping for WordPress's registration model: add_action, add_filter, admin-ajax, REST routes. Without it every PHP file is one file-level region.", + "The one-verdict-per-region limit above, which makes a file-level region under-report by construction.", + "Validation beyond a 13-line probe. `include`/`require` are marked unverified in the table and are PHP's most characteristic path bug.", + "No PHP pairs exist in the corpus -- 263 python, 351 c_cpp, 47 javascript, 49 java, 0 php -- so there is nothing to score a change against yet." + ] +} diff --git a/src/openultrasast/model/regions.py b/src/openultrasast/model/regions.py index 88c8db5..d0abfcc 100644 --- a/src/openultrasast/model/regions.py +++ b/src/openultrasast/model/regions.py @@ -36,7 +36,7 @@ _ACCESS_RANK: dict[str, float] = { # Languages whose facts the model layer carries. Anything else yields no region rather than an empty scan # that looks like a clean bill of health. -_LANGUAGES = {"python", "javascript", "typescript", "java", "c", "c_cpp"} +_LANGUAGES = {"python", "javascript", "typescript", "java", "c", "c_cpp", "php"} _NORMALISE = {"typescript": "javascript", "c_cpp": "c"} # What a CPG calls a file's top-level code. A module body is a method like any other to the engine, and diff --git a/src/openultrasast/ruleset/semantic/php.toml b/src/openultrasast/ruleset/semantic/php.toml new file mode 100644 index 0000000..3630147 --- /dev/null +++ b/src/openultrasast/ruleset/semantic/php.toml @@ -0,0 +1,158 @@ +# PHP semantic facts (contributor-scan 5.1, toward the v0.1 WordPress target). +# +# Written against a real PHP CPG rather than against PHP source, because the two do not look alike. Verified +# on 2026-09-09 with joern-parse 4.0.623 / php2cpg over a probe file: +# +# $_GET["name"] -> call name `.indexAccess`, code `$_GET["name"]` +# mysqli_query($conn, $sql) -> call name `mysqli_query` +# echo $_GET["msg"] -> call name `echo` +# "..." . $name . "..." -> call name `.concat` +# "... $clean ..." -> call name `encaps` +# +# That shapes the file: SOURCES are matched on call CODE (a superglobal is an index access, not a call by +# that name), and SINKS are matched on call NAME (PHP's dangerous operations are plain function calls). +# +# Entries marked `unverified` were not exercised by the probe. They are the ones to check first when a real +# WordPress checkout is scanned; nothing here should be trusted further than it has been run. + +version = "1" +language = "php" + +# --- sources ----------------------------------------------------------------------------------------- +# The superglobals. `$_SERVER` is included because its request-derived members (HTTP_USER_AGENT, HTTP_HOST, +# QUERY_STRING, REQUEST_URI) are attacker-controlled; its process members are not, which is a precision cost +# accepted here and worth revisiting once WordPress is measured. + +[[source]] +id = "request" +patterns = ["$_GET", "$_POST", "$_REQUEST", "$_COOKIE", "$_FILES"] + +[[source]] +id = "server" +patterns = ["$_SERVER"] + +[[source]] +id = "raw_body" +patterns = ["php://input"] + +# --- injection sinks --------------------------------------------------------------------------------- + +[[sink]] +id = "sql" +cwe = "CWE-89" +calls = [ + "mysqli_query", + "mysqli_multi_query", + "mysqli_real_query", + "mysql_query", + "pg_query", + "pg_send_query", + "sqlite_query", + "sqlite_unbuffered_query", +] +rule_ids = [] + +[[sink]] +id = "command" +cwe = "CWE-78" +calls = ["system", "exec", "shell_exec", "passthru", "proc_open", "popen", "pcntl_exec"] +rule_ids = [] + +[[sink]] +id = "code_eval" +cwe = "CWE-95" +calls = ["eval", "assert", "create_function"] +rule_ids = [] + +# --- output encoding --------------------------------------------------------------------------------- +# `echo` and `print` are language constructs and php2cpg does emit them as calls -- verified on the probe. + +[[sink]] +id = "echo" +cwe = "CWE-79" +calls = ["echo", "print", "printf", "vprintf", "print_r", "var_dump", "fwrite"] +rule_ids = [] + +# --- path -------------------------------------------------------------------------------------------- + +[[sink]] +id = "filesystem" +cwe = "CWE-22" +calls = [ + "fopen", + "file_get_contents", + "file_put_contents", + "readfile", + "unlink", + "copy", + "rename", + "scandir", + "opendir", + "mkdir", + "rmdir", + "move_uploaded_file", +] +rule_ids = [] + +# unverified: whether php2cpg emits `include`/`require` as calls or as their own node kind. If it does not, +# PHP's most characteristic path bug is invisible and this is the first thing to fix. +[[sink]] +id = "file_inclusion" +cwe = "CWE-98" +calls = ["include", "include_once", "require", "require_once"] +rule_ids = [] + +# --- deserialization --------------------------------------------------------------------------------- + +[[sink]] +id = "unserialize" +cwe = "CWE-502" +calls = ["unserialize"] +rule_ids = [] + +# --- untrusted destination --------------------------------------------------------------------------- + +[[sink]] +id = "outbound_request" +cwe = "CWE-918" +calls = ["curl_exec", "curl_setopt", "fsockopen", "stream_socket_client", "get_headers"] +rule_ids = [] + +[[sink]] +id = "redirect" +cwe = "CWE-601" +calls = ["header"] +rule_ids = [] + +# --- sanitizers -------------------------------------------------------------------------------------- +# A cleansing CALL only. Nothing here is a shape qualifier except where marked, and no entry may also appear +# as a sink: an operation that is its own discharge can never be reported, which is a bug this codebase has +# now hit three times in three different matchers. + +[[sanitizer]] +id = "html_escape" +calls = ["htmlspecialchars", "htmlentities", "html_entity_decode_safe"] + +[[sanitizer]] +id = "sql_escape" +calls = ["mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string", "pg_escape_literal"] + +[[sanitizer]] +id = "shell_escape" +calls = ["escapeshellarg", "escapeshellcmd"] + +[[sanitizer]] +id = "path_confine" +calls = ["basename", "realpath"] + +[[sanitizer]] +id = "coerce" +calls = ["intval", "floatval", "filter_var", "settype", "absint"] + +# A prepared statement is a SHAPE, not a cleansing call: `mysqli_prepare` makes the query safe by binding +# rather than by transforming the value, which is the same distinction `execute`/`executemany` carry in +# python.toml. +[[sanitizer]] +id = "prepared_statement" +calls = ["mysqli_prepare", "mysqli_stmt_bind_param", "pg_prepare", "pg_execute"] +parameterized = true diff --git a/tests/test_model_specs.py b/tests/test_model_specs.py index a9676df..04820da 100644 --- a/tests/test_model_specs.py +++ b/tests/test_model_specs.py @@ -241,3 +241,46 @@ def test_a_sink_is_matched_as_a_word_not_a_substring() -> None: assert "gets" in memory.sinks, "the unbounded read is a sink" assert "fgets" in memory.sources, "the bounded read is a SOURCE of untrusted input, never a sink" assert "fgets" not in memory.sinks + + +def test_php_facts_derive_the_families_a_web_application_needs() -> None: + """contributor-scan 5.1, toward the WordPress target. + + Written against a real PHP CPG rather than against PHP source, because the two do not look alike: a + superglobal is an `.indexAccess` whose CODE reads `$_GET["name"]`, while a dangerous operation + is a plain call named `mysqli_query` or `echo`. Sources therefore match on code and sinks on name. + """ + from openultrasast.model.specs import taint_specs + + families = taint_specs(language="php") + + assert {"injection", "output_encoding", "path", "deserialization", "untrusted_destination"} <= set(families) + injection = families["injection"] + assert "mysqli_query" in injection.sinks, "SQL" + assert "system" in injection.sinks, "command execution" + assert "$_GET" in injection.sources, "a superglobal is matched on the code of an index access" + assert "echo" in families["output_encoding"].sinks, "PHP's XSS sink is a language construct php2cpg emits as a call" + + +def test_no_php_sink_is_its_own_sanitizer() -> None: + """The bug class this codebase has hit three times, asserted for the newest fact table before it can.""" + from openultrasast.model.specs import taint_specs + + for family, spec in taint_specs(language="php").items(): + assert not set(spec.sinks) & set(spec.sanitizers), f"{family}: an operation that discharges itself can never be reported" + + +def test_a_php_file_can_become_a_region() -> None: + """Facts alone reach nothing: `_LANGUAGES` decides whether a file is offered to the model layer at all.""" + from openultrasast.model.regions import regions_for + + class _Target: + path = "wp-admin/admin-ajax.php" + language = "php" + loc = 100 + tags: tuple[str, ...] = () + + regions = regions_for([], [_Target()]) + + assert regions, "a php file with no entry point should still yield a file-level region" + assert regions[0].language == "php"