# 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 --------------------------------------------------------------------------------- # 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 = [] [[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]] # `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. id = "unserialize" cwe = "CWE-502" calls = ["unserialize", "maybe_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"] # Escapes, not cleansers: each backslashes quotes, so each protects a value that lands INSIDE a quoted # literal and nothing else. `ORDER BY u.{$sortby}` and `WHERE id = " . $n` are injectable escaped -- Ultimate # Member CVE-2024-1071 is the first, and it read as sanitized. `pg_escape_literal` adds its own quotes and is # a cleanser. [[sanitizer]] id = "sql_escape" calls = ["mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string", "esc_sql"] quoted_only = true [[sanitizer]] id = "sql_literal" calls = ["pg_escape_literal"] # `$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. # An allowlist CHECK. Ultimate Member's fix for CVE-2024-1071 is exactly this, twice over: # `$order = in_array( strtoupper( $order ), array( 'ASC', 'DESC' ), true ) ? $order : 'ASC'` and # `elseif ( in_array( $sortby, $this->core_search_fields, true ) ) { ... }`. A guard, not a cleanser -- see # `guard` in semantic/facts.py. [[sanitizer]] id = "allowlist" calls = ["in_array"] guard = true [[sanitizer]] id = "wpdb_prepare" calls = ["$wpdb->prepare"] [[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 # 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"] # 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 # A hook callback is handed its arguments by `do_action`/`apply_filters` -- a WP_User, a post id, a filtered # value -- not by the request, so its parameters are not attacker input. A shortcode's `$atts` come from post # content a contributor writes, which is input, so `add_shortcode` keeps the parameter rule. request_arguments = ["add_shortcode"] # 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/"]