mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
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 `<operator>.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) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
b5d3cd6ab6
commit
61811512a8
@@ -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
|
||||
|
||||
@@ -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 `<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 ---------------------------------------------------------------------------------
|
||||
|
||||
[[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
|
||||
Reference in New Issue
Block a user