From 7927d6e5391e9600756750e0ea6307dbd5d6f0b5 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:05:47 +0200 Subject: [PATCH] feat(ruleset): PHP quick-mode rules from the engine's vocabulary, measured on the development corpus Quick mode had no PHP rules, so a PHP quick scan read the files and reported a plausible zero. ruleset/php/rules.toml adds 13 line/statement rules built on semantic/php.toml's sinks, sources and sanitizers (sql/wpdb, command, code_eval, unserialize, echo, file_inclusion, filesystem, outbound_request, redirect). 9 are enabled and 4 are shadow (sql-call-composition, code-eval, unserialize, xss-echo-input). A rule is shadow when it fired on the corpus but never at a known site. precision_estimate is the measured lower bound. Measured in-sample on the development corpus only (pmpro introduce/repair/benign, population-v1 PHP cases; population-v2 was not read). Known site hit on the vulnerable pin in 4 of 5 cases (LearnPress missed). The hit disappears on the fixed pin for pmpro and YesWiki. Numbers, per-pin files/bytes read and the engine comparison are in benchmarks/measurements/2026-09-30-php-quick-rules/. - fixtures php-vulnerable / php-benign plus manifests: ousast benchmark covers PHP (9/14 reported, 13/14 including shadow; the benign twin records 2 known false alerts) - PHP `#` comments are comments; `#[...]` attributes are not - the alerts task now reports PHP as quick coverage; alerts-engine keeps running the engine for PHP next to the quick rules (ENGINE_ALONGSIDE_QUICK) - other languages' benchmark findings and metrics are unchanged Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo --- benchmarks/fixtures/php-benign/app.php | 89 ++ benchmarks/fixtures/php-vulnerable/app.php | 87 ++ benchmarks/fixtures/php-vulnerable/safe.php | 65 ++ benchmarks/manifests/php-benign.toml | 12 + benchmarks/manifests/php-vulnerable.toml | 136 +++ .../measure_php_quick.py | 181 ++++ .../measurement.json | 864 ++++++++++++++++++ src/openultrasast/cli.py | 2 +- src/openultrasast/findings.py | 2 + src/openultrasast/plane/engine_alerts.py | 23 +- src/openultrasast/ruleset/php/rules.toml | 190 ++++ tests/test_benchmark.py | 6 +- tests/test_php_quick_rules.py | 81 ++ tests/test_plane_engine_alerts.py | 6 +- tests/test_plane_loop.py | 25 +- tests/test_ruleset.py | 5 +- 16 files changed, 1757 insertions(+), 17 deletions(-) create mode 100644 benchmarks/fixtures/php-benign/app.php create mode 100644 benchmarks/fixtures/php-vulnerable/app.php create mode 100644 benchmarks/fixtures/php-vulnerable/safe.php create mode 100644 benchmarks/manifests/php-benign.toml create mode 100644 benchmarks/manifests/php-vulnerable.toml create mode 100644 benchmarks/measurements/2026-09-30-php-quick-rules/measure_php_quick.py create mode 100644 benchmarks/measurements/2026-09-30-php-quick-rules/measurement.json create mode 100644 src/openultrasast/ruleset/php/rules.toml create mode 100644 tests/test_php_quick_rules.py diff --git a/benchmarks/fixtures/php-benign/app.php b/benchmarks/fixtures/php-benign/app.php new file mode 100644 index 0000000..7633142 --- /dev/null +++ b/benchmarks/fixtures/php-benign/app.php @@ -0,0 +1,89 @@ +get_var($wpdb->prepare("SELECT id FROM {$wpdb->prefix}orders WHERE code = %s LIMIT 1", $code)); +} + +function find_user($conn) +{ + $stmt = mysqli_prepare($conn, 'SELECT * FROM users WHERE name = ?'); + mysqli_stmt_bind_param($stmt, 's', $_GET['name']); + mysqli_stmt_execute($stmt); + return mysqli_stmt_get_result($stmt); +} + +function members_sorted($order) +{ + $order = in_array(strtoupper($order), array('ASC', 'DESC'), true) ? strtoupper($order) : 'ASC'; + return ' ORDER BY u.user_login ' . ('DESC' === $order ? 'DESC' : 'ASC') . ' '; +} + +function create_form($data) +{ + return 'INSERT INTO forms (id, label) VALUES (' . intval($data['id']) . ', "x")'; +} + +function captcha_cleanup($db, $userAgent) +{ + $delete = sprintf( + "DELETE FROM captcha WHERE useragent = '%s'", + $db->escape($userAgent) + ); + $db->query($delete); +} + +function ping() +{ + system('ping -c 1 ' . escapeshellarg($_GET['host'])); +} + +function calculate($formula) +{ + return is_numeric($formula) ? (float) $formula : 0.0; +} + +function restore_session() +{ + return json_decode($_COOKIE['session'], true); +} + +function greet() +{ + echo 'Hello ' . htmlspecialchars($_GET['name'], ENT_QUOTES); +} + +function page() +{ + $pages = array('home', 'about'); + include in_array($_GET['page'], $pages, true) ? $_GET['page'] . '.php' : 'home.php'; +} + +function download() +{ + readfile('/var/www/files/' . basename($_GET['file'])); +} + +function fetch_preview() +{ + $url = filter_var($_GET['url'], FILTER_VALIDATE_URL); + $ch = curl_init(wp_http_validate_url($url) ? $url : 'about:blank'); + return curl_exec($ch); +} + +function go_back() +{ + wp_safe_redirect(wp_validate_redirect(wp_unslash($_GET['next']), home_url())); +} + +function find_order($conn) +{ + $id = intval($_POST['id']); + $sql = 'SELECT * FROM orders WHERE id = ' . $id; + return mysqli_query($conn, $sql); +} diff --git a/benchmarks/fixtures/php-vulnerable/app.php b/benchmarks/fixtures/php-vulnerable/app.php new file mode 100644 index 0000000..91a3644 --- /dev/null +++ b/benchmarks/fixtures/php-vulnerable/app.php @@ -0,0 +1,87 @@ +get_var("SELECT id FROM {$wpdb->prefix}orders WHERE code = '" . $code . "' LIMIT 1"); +} + +function find_user($conn) +{ + return mysqli_query($conn, "SELECT * FROM users WHERE name = '" . $_GET['name'] . "'"); +} + +function members_sorted($order) +{ + $order = esc_sql($order); // escaping does not protect an unquoted ORDER BY + return " ORDER BY u.user_login {$order} "; +} + +function create_form($data) +{ + return 'INSERT INTO forms (id, label) VALUES (' . $data['id'] . ', "x")'; +} + +function captcha_cleanup($db, $userAgent) +{ + $delete = sprintf( + "DELETE FROM captcha WHERE useragent = '%s'", + $userAgent + ); + $db->query($delete); +} + +function ping() +{ + system('ping -c 1 ' . $_GET['host']); +} + +function calculate($formula) +{ + eval('$value = ' . $formula . ';'); + return $value; +} + +function restore_session() +{ + return unserialize($_COOKIE['session']); +} + +function greet() +{ + echo 'Hello ' . $_GET['name']; +} + +function page() +{ + include $_GET['page'] . '.php'; +} + +function download() +{ + readfile('/var/www/files/' . $_GET['file']); +} + +function fetch_preview() +{ + $ch = curl_init($_GET['url']); + return curl_exec($ch); +} + +function go_back() +{ + header('Location: ' . $_GET['next']); +} + +// Not visible to a line rule: the value reaches the sink through a variable on another line. +function find_order($conn) +{ + $id = $_POST['id']; + $sql = "SELECT * FROM orders WHERE id = '" . $id . "'"; + return mysqli_query($conn, $sql); +} diff --git a/benchmarks/fixtures/php-vulnerable/safe.php b/benchmarks/fixtures/php-vulnerable/safe.php new file mode 100644 index 0000000..f4f282e --- /dev/null +++ b/benchmarks/fixtures/php-vulnerable/safe.php @@ -0,0 +1,65 @@ +get_var($wpdb->prepare("SELECT id FROM {$wpdb->prefix}orders WHERE code = %s LIMIT 1", $code)); +} + +function orders_in_safe($ids) +{ + global $wpdb; + $ids_format = implode(', ', array_fill(0, count($ids), '%d')); + return $wpdb->prepare('SELECT * FROM orders WHERE id IN (' . $ids_format . ')', $ids); +} + +function page_limit_safe($page) +{ + return ' LIMIT ' . intval($page) . ', 20'; +} + +function quoted_escaped_safe($name) +{ + return "SELECT * FROM users WHERE name = '" . esc_sql($name) . "'"; +} + +function ping_safe() +{ + system('ping -c 1 ' . escapeshellarg($_GET['host'])); +} + +function version_safe() +{ + exec('git describe --tags', $output); + return $output; +} + +function greet_safe() +{ + echo 'Hello ' . htmlspecialchars($_GET['name'], ENT_QUOTES); +} + +function download_safe() +{ + readfile('/var/www/files/' . basename($_GET['file'])); +} + +function restore_safe($raw) +{ + return unserialize($raw, ['allowed_classes' => false]); +} + +function query_args_safe() +{ + $args = []; + $args['include'] = [(int) $_REQUEST['p']]; + return $args; +} + +function comment_only() +{ + // system('rm -rf ' . $_GET['dir']) would be dangerous but this is a comment + # eval($_POST['code']) is a comment too + return 'ok'; +} diff --git a/benchmarks/manifests/php-benign.toml b/benchmarks/manifests/php-benign.toml new file mode 100644 index 0000000..895c845 --- /dev/null +++ b/benchmarks/manifests/php-benign.toml @@ -0,0 +1,12 @@ +name = "php-benign" +# The repaired twin of php-vulnerable: no expected findings, so every quick-mode finding is a false alert. +language = "php_web" +frameworks = ["wordpress"] +setup = [] +modes = ["quick", "standard"] +known_noise = [] +baselines = [] + +[source] +type = "local" +path = "../fixtures/php-benign" diff --git a/benchmarks/manifests/php-vulnerable.toml b/benchmarks/manifests/php-vulnerable.toml new file mode 100644 index 0000000..5053e3b --- /dev/null +++ b/benchmarks/manifests/php-vulnerable.toml @@ -0,0 +1,136 @@ +name = "php-vulnerable" +language = "php_web" +frameworks = ["wordpress"] +setup = [] +modes = ["quick", "standard"] +known_noise = [] +baselines = [] + +[source] +type = "local" +path = "../fixtures/php-vulnerable" + +[[expected]] +cwe = "CWE-89" +class = "sql injection" +path = "app.php" +line = 11 +rule_id = "php-wpdb-sql-composition" +sink = "get_var" +evidence = "wpdb get_var query concatenates the order code without prepare" + +[[expected]] +cwe = "CWE-89" +class = "sql injection" +path = "app.php" +line = 16 +rule_id = "php-sql-call-composition" +sink = "mysqli_query" +evidence = "mysqli_query composes the query from request input" + +[[expected]] +cwe = "CWE-89" +class = "sql injection" +path = "app.php" +line = 22 +rule_id = "php-sql-unquoted-interpolation" +sink = "ORDER" +evidence = "esc_sql value interpolated into an unquoted ORDER BY" + +[[expected]] +cwe = "CWE-89" +class = "sql injection" +path = "app.php" +line = 27 +rule_id = "php-sql-unquoted-concat" +sink = "VALUES" +evidence = "form id concatenated unquoted into VALUES" + +[[expected]] +cwe = "CWE-89" +class = "sql injection" +path = "app.php" +line = 32 +rule_id = "php-sql-sprintf-unescaped" +sink = "sprintf" +evidence = "sprintf SQL with an unescaped quoted %s user agent" + +[[expected]] +cwe = "CWE-78" +class = "command injection" +path = "app.php" +line = 41 +rule_id = "php-command-injection" +sink = "system" +evidence = "system runs a command built from request input" + +[[expected]] +cwe = "CWE-95" +class = "code injection" +path = "app.php" +line = 46 +rule_id = "php-code-eval" +sink = "eval" +evidence = "eval executes a formula string" + +[[expected]] +cwe = "CWE-502" +class = "insecure deserialization" +path = "app.php" +line = 52 +rule_id = "php-unserialize" +sink = "unserialize" +evidence = "unserialize of a cookie" + +[[expected]] +cwe = "CWE-79" +class = "cross-site scripting" +path = "app.php" +line = 57 +rule_id = "php-xss-echo-input" +sink = "echo" +evidence = "request input echoed without escaping" + +[[expected]] +cwe = "CWE-98" +class = "file inclusion" +path = "app.php" +line = 62 +rule_id = "php-file-inclusion-input" +sink = "include" +evidence = "include of a request-chosen path" + +[[expected]] +cwe = "CWE-22" +class = "path traversal" +path = "app.php" +line = 67 +rule_id = "php-path-input" +sink = "readfile" +evidence = "readfile of a request-chosen path" + +[[expected]] +cwe = "CWE-918" +class = "server-side request forgery" +path = "app.php" +line = 72 +rule_id = "php-ssrf-input" +sink = "curl_init" +evidence = "curl request to a request-chosen URL" + +[[expected]] +cwe = "CWE-601" +class = "open redirect" +path = "app.php" +line = 78 +rule_id = "php-open-redirect" +sink = "header" +evidence = "Location header from request input" + +[[expected]] +cwe = "CWE-89" +class = "sql injection" +path = "app.php" +line = 86 +sink = "mysqli_query" +evidence = "query built from a request value on an earlier line (not visible to a line rule)" diff --git a/benchmarks/measurements/2026-09-30-php-quick-rules/measure_php_quick.py b/benchmarks/measurements/2026-09-30-php-quick-rules/measure_php_quick.py new file mode 100644 index 0000000..8688e65 --- /dev/null +++ b/benchmarks/measurements/2026-09-30-php-quick-rules/measure_php_quick.py @@ -0,0 +1,181 @@ +"""Measure the PHP quick-mode rules on the PHP development corpus (never population-v2). + +Pins: pmpro vulnerable / fixed / benign (benchmarks/push/inputs.json, role development) and the spent +population-v1 PHP cases' vulnerable and fixed pins, exported beforehand with benchmarks/independent/evaluate.py's +`export` into /checkouts//