2026-09-09 14:18:57 +02:00
|
|
|
# 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 `<operator>.indexAccess`, code `$_GET["name"]`
|
|
|
|
|
# mysqli_query($conn, $sql) -> call name `mysqli_query`
|
|
|
|
|
# echo $_GET["msg"] -> call name `echo`
|
|
|
|
|
# "..." . $name . "..." -> call name `<operator>.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 ---------------------------------------------------------------------------------
|
|
|
|
|
|
2026-09-09 16:13:17 +02:00
|
|
|
# WordPress does not call mysqli directly; it goes through $wpdb. Verified against a CPG: `$wpdb->get_var(..)`
|
|
|
|
|
# is a call NAMED `get_var` whose CODE carries the qualifier, so the qualified form is what belongs here. A
|
|
|
|
|
# bare `get_var` or `query` would match any method of that name anywhere -- the generic-name trap that made
|
|
|
|
|
# `query.get` match every `.get(` in a repository.
|
|
|
|
|
#
|
|
|
|
|
# `$wpdb->insert`, `->update` and `->delete` are deliberately absent: they take arrays and WordPress prepares
|
|
|
|
|
# them itself, so they are not injection sinks.
|
|
|
|
|
[[sink]]
|
|
|
|
|
id = "wpdb"
|
|
|
|
|
cwe = "CWE-89"
|
|
|
|
|
calls = [
|
|
|
|
|
"$wpdb->get_var",
|
|
|
|
|
"$wpdb->get_results",
|
|
|
|
|
"$wpdb->get_row",
|
|
|
|
|
"$wpdb->get_col",
|
|
|
|
|
"$wpdb->query",
|
|
|
|
|
"$wpdb->replace",
|
|
|
|
|
]
|
|
|
|
|
rule_ids = []
|
|
|
|
|
|
2026-09-09 14:18:57 +02:00
|
|
|
[[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]]
|
2026-09-20 18:09:57 +02:00
|
|
|
# `maybe_unserialize` is WordPress's own wrapper and the form every plugin in this project's corpus actually
|
|
|
|
|
# writes: across four plugins and 1,164 PHP files there is exactly one deserialization site, and it is spelled
|
|
|
|
|
# that way. It is not reached by the bare token, and correctly so -- the matcher is word-bounded, and the
|
|
|
|
|
# character before `unserialize` inside `maybe_unserialize` is a word character. A token that a repository
|
|
|
|
|
# never spells is a family that can never answer, which is what the 2026-09-19 census recorded for this one.
|
|
|
|
|
#
|
|
|
|
|
# It is a real entry point rather than a safe wrapper: `maybe_unserialize` calls `unserialize` whenever the
|
|
|
|
|
# string looks serialized, and only returns the value untouched when it does not.
|
2026-09-09 14:18:57 +02:00
|
|
|
id = "unserialize"
|
|
|
|
|
cwe = "CWE-502"
|
2026-09-20 18:09:57 +02:00
|
|
|
calls = ["unserialize", "maybe_unserialize"]
|
2026-09-09 14:18:57 +02:00
|
|
|
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"
|
2026-09-09 16:13:17 +02:00
|
|
|
calls = ["mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string", "pg_escape_literal", "esc_sql"]
|
|
|
|
|
|
|
|
|
|
# `$wpdb->prepare` is WordPress's parameterized form and appears ON the flow path, so it reads as a cleansing
|
|
|
|
|
# call rather than as a shape qualifier.
|
|
|
|
|
#
|
|
|
|
|
# NOTE what is NOT here. `sanitize_text_field`, `esc_html` and `esc_attr` are real WordPress sanitizers and
|
|
|
|
|
# none of them stops SQL injection -- CVE-2023-23488 is exactly a `sanitize_text_field` value concatenated
|
|
|
|
|
# into a query. Adding them would make that CVE, and its whole class, invisible. The sanitizer list is
|
|
|
|
|
# currently flat across families, which cannot express "escapes for HTML but not for SQL"; until it can, only
|
|
|
|
|
# SQL-specific cleansers belong here.
|
|
|
|
|
[[sanitizer]]
|
|
|
|
|
id = "wpdb_prepare"
|
|
|
|
|
calls = ["$wpdb->prepare"]
|
2026-09-09 14:18:57 +02:00
|
|
|
|
|
|
|
|
[[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
|
2026-09-10 07:32:20 +02:00
|
|
|
|
|
|
|
|
# A registry that connects a producer to a consumer through a STRING key rather than a call. Neither end
|
|
|
|
|
# names the other, so no CPG frontend can draw the edge: php2cpg does not even keep the callback argument,
|
|
|
|
|
# and `add_filter("hook", array($this, "m"))` reaches the graph as `add_filter("hook", )`.
|
|
|
|
|
#
|
|
|
|
|
# WordPress is what forced this into existence and none of it is about WordPress. The shape is general --
|
|
|
|
|
# Django signals, jQuery events, Symfony's event dispatcher, any plugin system -- and a second framework is
|
|
|
|
|
# a row here rather than a change to the query. That is the whole reason it is a fact and not a constant.
|
|
|
|
|
[[dispatch]]
|
|
|
|
|
id = "wordpress_hooks"
|
|
|
|
|
register = ["add_action", "add_filter", "add_shortcode"]
|
|
|
|
|
apply = ["apply_filters", "apply_filters_ref_array", "do_action", "do_action_ref_array"]
|
2026-09-24 08:36:28 +02:00
|
|
|
# A filter returns its VALUE argument, filtered; its further arguments are context and never the result, and
|
|
|
|
|
# an action returns nothing. Without this, `apply_filters('pmpro_random_code', $code, $this)` returned
|
|
|
|
|
# whatever `$this` carried, and a random md5 fragment became an SQL injection.
|
|
|
|
|
returns = ["apply_filters", "apply_filters_ref_array"]
|
|
|
|
|
value_arg = 2
|
2026-09-11 14:27:50 +02:00
|
|
|
|
|
|
|
|
# What a PHP repository's paths say about scope. `vendored` trees are out of the scan and out of the graph
|
|
|
|
|
# (a bundled library is a separate unit); `tests` paths stay in scope but are not product, so their
|
|
|
|
|
# regions are ordered after the product's. Measured before this row: 207 of WP Statistics's 357 PHP files
|
|
|
|
|
# were vendored and two of them were 60% of its graph; 14 of the 67 regions ahead of MW WP Form's CVE
|
|
|
|
|
# were test-mail.php and test-mail-parser.php.
|
|
|
|
|
[[layout]]
|
|
|
|
|
id = "php_composer_and_wordpress"
|
|
|
|
|
vendored = ["vendor/", "node_modules/", "third_party/", "third-party/", "lib/vendor/"]
|
|
|
|
|
tests = ["tests/", "test/", "spec/", "test-*.php", "*Test.php", "*.test.php", "*_test.php", "phpunit/"]
|