Files
Open_Ultra_SAST/src/openultrasast/ruleset/semantic/php.toml
T
Marius CiepluchandClaude Opus 5.5 03f2ce7162 feat(taint): guards -- a validator or allowlist check makes a value safe where it held
Three of the four fixed-side alerts left in the independent population had one
cause: the fix was a check, and checks were not modelled.
- wger's fix is `url_has_allowed_host_and_scheme`: `if not ...(next_url):
  next_url = reverse(..)` and `if next_url and ...(next_url): return redirect`.
- Ultimate Member's fix is an allowlist: `in_array(strtoupper($order), [...],
  true) ? $order : 'ASC'` and `elseif (in_array($sortby, $allowed, true))`.
(The fourth, Signal K, is `assertAllowedHost`, a project-local function. It is not
covered.)

Sanitizer facts can now say `guard = true`. A guard is a check, never a cleansing
call on a path. A flow is discharged only when a guard tests a variable the path
carries, and the flow is in one of these places:
- inside the branch whose condition requires the check. The guard may be combined
  with `&&` only;
- past a failure test, a single `!` combined with `||` only, whose branch exits
  (return, exit, die, wp_die, throw) or overwrites the value, and whose condition
  dominates the flow's end;
- through a ternary whose condition requires the check, where the other arm cannot
  carry the value. The engine can enter through the condition's own copy of the
  value, which never becomes the result.

Facts: `in_array` (PHP) and Django's `url_has_allowed_host_and_scheme` /
`is_safe_url` (Python). A row now consults guards even when a request lists no
sanitizers.

Two Joern-gated tests cover the discharged shapes and, as controls, an unguarded
redirect, a guard on a different variable, a failure that is only logged, the
unguarded else branch, and the ternary arm the check does not select. Both tests
fail without the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
2026-09-25 16:56:15 +02:00

248 lines
9.9 KiB
TOML

# 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 ---------------------------------------------------------------------------------
# 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/"]