mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45d02ecf20
commit
7927d6e539
@@ -275,7 +275,7 @@ def _main(argv: list[str] | None) -> int:
|
||||
plane_remember.add_argument("--population", help="the population the run measured (default: the Run's annotation)")
|
||||
plane_remember.add_argument("--split", help="the split of that population (default: the Run's annotation)")
|
||||
plane_engine = plane_sub.add_parser(
|
||||
"alerts-engine", help="a Run's `alerts` for languages quick mode does not cover (PHP), from the engine image on this host"
|
||||
"alerts-engine", help="a Run's `alerts` for PHP and languages quick mode does not cover, from the engine image on this host"
|
||||
)
|
||||
plane_engine.add_argument("run_manifest", type=Path)
|
||||
plane_engine.add_argument(
|
||||
|
||||
@@ -196,6 +196,8 @@ def _match_is_in_comment(text: str, offset: int, language: str = "") -> bool:
|
||||
return True
|
||||
comment_column = line.find("#")
|
||||
return comment_column != -1 and comment_column < column
|
||||
if language == "php" and stripped.startswith("#") and not stripped.startswith("#["):
|
||||
return True # PHP also has `#` line comments; `#[...]` is a PHP 8 attribute, not a comment
|
||||
if stripped.startswith(("//", "/*", "*")):
|
||||
return True
|
||||
comment_column = line.find("//")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Engine alerts for the languages quick mode does not cover, produced on the host (harnessx-removal Req 6.3).
|
||||
"""Engine alerts for the languages quick mode does not cover, and for PHP beside its quick rules
|
||||
(``ENGINE_ALONGSIDE_QUICK``), produced on the host (harnessx-removal Req 6.3).
|
||||
|
||||
The loop's ``alerts`` task runs the quick-mode scan, which has rules only for the languages its ruleset declares; a
|
||||
PHP case yields zero alerts that mean nothing (``coverage: none``). PHP detection comes from the Joern taint engine
|
||||
(``ruleset/semantic/php.toml``), which runs in the ``openultrasast:dev`` image, not in the runner sandbox. So
|
||||
``ousast plane alerts-engine <Run.yaml>`` does the ``alerts`` task's work on the host for every case whose files
|
||||
include a language quick mode does not cover but the engine does:
|
||||
case in any other language yields zero alerts that mean nothing (``coverage: none``). PHP has quick rules since
|
||||
2026-09-30 (``ruleset/php/rules.toml``, line patterns over the engine's vocabulary), but its detection of record is
|
||||
still the Joern taint engine (``ruleset/semantic/php.toml``), which runs in the ``openultrasast:dev`` image, not in
|
||||
the runner sandbox. So ``ousast plane alerts-engine <Run.yaml>`` does the ``alerts`` task's work on the host for
|
||||
every case whose files include a language the engine models and quick mode does not cover, or PHP:
|
||||
|
||||
- export the case's vulnerable and fixed pins from the case cache (``git archive``, as
|
||||
``benchmarks/independent/evaluate.py`` does), run the quick scan on each (:func:`.tasks.alerts.scan`) and the
|
||||
@@ -42,6 +44,10 @@ IMAGE = "openultrasast:dev"
|
||||
MEMORY = "3g"
|
||||
DEADLINE = 1800.0
|
||||
GRACE = 600.0 # the container's own startup and teardown beyond the scan deadline
|
||||
# Languages whose alerts of record come from the engine even though quick mode has rules for them. PHP's quick rules
|
||||
# (ruleset/php, 2026-09-30) are a line layer with a low measured precision lower bound on the development corpus, so
|
||||
# the engine keeps running for PHP beside them, and the coverage reports the engine for it.
|
||||
ENGINE_ALONGSIDE_QUICK = frozenset({"php"})
|
||||
CACHE = Path.home() / ".cache" / "openultrasast" / "independent"
|
||||
DUMP = Path("benchmarks") / "push" / "finding_dump.py"
|
||||
_READ = re.compile(r"^read (\d+) files, (\d+) bytes", re.MULTILINE)
|
||||
@@ -193,7 +199,10 @@ def produce(
|
||||
files[role] = read.pop("languages")
|
||||
if read["files"] == 0:
|
||||
raise EngineAlertsError(f"{case.case_id} {role}: the quick scan read no file of {checkout}")
|
||||
needed |= {lang for lang in files[role] if lang != "unknown" and not covers(quick, lang) and lang in engine}
|
||||
needed |= {
|
||||
lang for lang in files[role]
|
||||
if lang != "unknown" and lang in engine and (not covers(quick, lang) or lang in ENGINE_ALONGSIDE_QUICK)
|
||||
} # fmt: skip
|
||||
if not needed:
|
||||
return {"case": case.case_id, "status": "skipped", "reason": "quick mode covers every language read", "languages": files}
|
||||
out_dir = work / "out"
|
||||
@@ -216,7 +225,7 @@ def produce(
|
||||
summary["in_fix_range"][role] = sum(1 for r in found if r["in_fix_range"] is True)
|
||||
keys = ("files", "bytes", "questions", "completed", "seconds", "degradations", "exit")
|
||||
summary["engine"][role] = {**{k: record.get(k) for k in keys}, "findings": len(record.get("findings") or [])}
|
||||
cov = coverage(files, quick, needed)
|
||||
cov = coverage(files, set(quick) - needed, needed) # a language the engine ran for is reported as the engine's
|
||||
summary.update(
|
||||
status="done", units_done=len(ROLES), units_total=len(ROLES), usd=0, calls=0, usage={}, model=None, source="engine",
|
||||
host=True, family=case.family, image=_image_id(image, runner), deadline=deadline, quick_languages=sorted(quick),
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# PHP quick-mode rules: line/statement patterns over the engine's PHP vocabulary (semantic/php.toml).
|
||||
#
|
||||
# The engine (Joern taint, standard/deep) owns PHP data flow. These rules are the quick layer: they see one line
|
||||
# or one statement, so each one pairs a sink family from semantic/php.toml with the shape that makes it dangerous
|
||||
# WITHOUT flow -- a request superglobal on the sink's own line, or a query literal composed with a variable -- and
|
||||
# excludes the lines that carry that family's sanitizer. Sinks, sources and sanitizers are the engine's words:
|
||||
# sources $_GET $_POST $_REQUEST $_COOKIE ($_SERVER, $_FILES, php://input where the family reads them)
|
||||
# sanitizers $wpdb->prepare, the prepared_statement shape, sql_escape (quoted positions ONLY -- quoted_only),
|
||||
# coerce (intval/absint/floatval), html_escape, shell_escape, path_confine (basename/realpath)
|
||||
#
|
||||
# Measured on the PHP development corpus only (benchmarks/measurements/2026-09-30-php-quick-rules/): pmpro
|
||||
# introduce/repair/benign and the spent population-v1 PHP cases -- never population-v2. `status` and
|
||||
# `precision_estimate` come from that measurement: a rule whose firings on the corpus were not at a known site
|
||||
# is `shadow`, and precision_estimate is the fraction of corpus + fixture firings that were at a labeled site
|
||||
# (a lower bound: an unlabeled firing is counted false).
|
||||
|
||||
# --- SQL (sql, wpdb) ---------------------------------------------------------------------------------------
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-wpdb-sql-composition"
|
||||
title = "WordPress $wpdb query composed from a variable without $wpdb->prepare"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-89"
|
||||
tags = ["injection"]
|
||||
status = "enabled"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 0.032
|
||||
version = "1"
|
||||
pattern = '''\$wpdb->(get_var|get_results|get_row|get_col|query|replace)\s*\(\s*(?!\$wpdb->prepare)(?:"[^"\n]*\{?\$(?!wpdb->)[A-Za-z_]|[^\n;]*?["']\s*\.\s*\$(?!wpdb->)[A-Za-z_]|[^\n;]*?\$_(?:GET|POST|REQUEST|COOKIE)\b)'''
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-sql-call-composition"
|
||||
title = "PHP SQL call composed from a variable or request input"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-89"
|
||||
tags = ["injection"]
|
||||
status = "shadow"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 0.25
|
||||
version = "1"
|
||||
pattern = '''(?<![\w>:$\\])(mysqli_query|mysqli_multi_query|mysqli_real_query|mysql_query|pg_query|pg_send_query|sqlite_query|sqlite_unbuffered_query)\s*\((?:\s*"[^"\n]*\{?\$[A-Za-z_]|[^,\n;]*,\s*"[^"\n]*\{?\$[A-Za-z_]|[^\n;]*?["']\s*\.\s*\$[A-Za-z_]|[^\n;]*?\$_(?:GET|POST|REQUEST|COOKIE)\b)'''
|
||||
|
||||
# A variable in an UNQUOTED SQL position -- a number, an identifier, ORDER BY / LIMIT / IN (...) / VALUES (...).
|
||||
# The engine's sql_escape family is quoted_only: esc_sql and mysqli_real_escape_string protect a value inside a
|
||||
# quoted literal and nothing else, so an escaped value here is still injectable (Ultimate Member CVE-2024-1071,
|
||||
# YesWiki CVE-2026-46670). Coercion (intval/absint/(int)) is the fix, and a coerced value does not match.
|
||||
[[rule]]
|
||||
rule_id = "php-sql-unquoted-concat"
|
||||
title = "PHP SQL literal concatenates a variable into an unquoted position"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-89"
|
||||
tags = ["injection"]
|
||||
status = "enabled"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 0.034
|
||||
version = "1"
|
||||
pattern = '''(?<![$\w>])(?=(VALUES|IN|ORDER|GROUP|LIMIT|OFFSET|WHERE|AND|OR|SET|HAVING)\b)(?:(?:VALUES|IN)\s*\(|(?:ORDER|GROUP)\s+BY\b[^"'\n]*|(?:LIMIT|OFFSET)\s+(?:\d+\s*,\s*)?|(?:WHERE|AND|OR|SET|HAVING)\s+[\w.`]+\s*(?:=|<>|!=|<=|>=|<|>|LIKE)\s*)["']\s*\.\s*\$(?!wpdb->)(?!\w*(?:format|placeholder))[A-Za-z_]'''
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-sql-unquoted-interpolation"
|
||||
title = "PHP SQL string interpolates a variable into an unquoted position"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-89"
|
||||
tags = ["injection"]
|
||||
status = "enabled"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 0.129
|
||||
version = "1"
|
||||
pattern = '''(?<![$\w>])(?=(VALUES|IN|ORDER|GROUP|LIMIT|OFFSET|WHERE|AND|OR|SET|HAVING)\b)(?:(?:VALUES|IN)\s*\(\s*|(?:ORDER|GROUP)\s+BY\b[^"'\n;]*?|(?:LIMIT|OFFSET)\s+(?:\d+\s*,\s*)?|(?:WHERE|AND|OR|SET|HAVING)\s+[\w.`]+\s*(?:=|<>|!=|<=|>=|<|>|LIKE)\s*)\{?\$(?!wpdb->)(?!\w*(?:format|placeholder))[A-Za-z_]'''
|
||||
|
||||
# sprintf-built SQL with a quoted %s and no escaping or coercion anywhere in the statement (phpMyFAQ
|
||||
# CVE-2026-46364). Statement-scoped, so it spans lines up to the closing `);`.
|
||||
[[rule]]
|
||||
rule_id = "php-sql-sprintf-unescaped"
|
||||
title = "PHP sprintf-built SQL with an unescaped %s value"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-89"
|
||||
tags = ["injection"]
|
||||
status = "enabled"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 0.109
|
||||
version = "1"
|
||||
pattern = '''(sprintf)\(\s*["']\s*(?:SELECT|INSERT|UPDATE|DELETE|REPLACE)\b(?=(?:(?!\);)[\s\S]){0,2000}?'%s')(?=(?:(?![Ee]scape|quote\(|intval\(|absint\(|\(int\)|esc_sql\()[\s\S]){0,2000}?\);)'''
|
||||
|
||||
# --- command / code (command, code_eval) -------------------------------------------------------------------
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-command-injection"
|
||||
title = "PHP shell command built from a variable without escapeshellarg"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-78"
|
||||
tags = ["syscall_entry"]
|
||||
status = "enabled"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 1.0
|
||||
version = "1"
|
||||
pattern = '''(?<![\w>:$\\])(?<!function )(system|exec|shell_exec|passthru|popen|proc_open|pcntl_exec)\s*\((?![^\n]*escapeshell(?:arg|cmd)\s*\()[^,\n;]*\$[A-Za-z_]'''
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-code-eval"
|
||||
title = "PHP code evaluation of a variable or request input"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-95"
|
||||
tags = ["syscall_entry"]
|
||||
status = "shadow"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 0.333
|
||||
version = "1"
|
||||
pattern = '''(?<![\w>:$\\])(?<!function )(?=(eval|create_function|assert)\b)(?:(?:eval|create_function)\s*\([^\n;]*\$[A-Za-z_]|assert\s*\([^\n;]*\$_(?:GET|POST|REQUEST|COOKIE)\b)'''
|
||||
|
||||
# --- deserialization (unserialize) -------------------------------------------------------------------------
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-unserialize"
|
||||
title = "PHP unserialize of a variable or request input without allowed_classes"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-502"
|
||||
tags = ["deserialization"]
|
||||
status = "shadow"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 0.25
|
||||
version = "1"
|
||||
# `maybe_unserialize` (WordPress) runs on every option and meta read, so it counts only with request input.
|
||||
pattern = '''(?<![\w>:$\\])(?<!function )(?=(unserialize|maybe_unserialize)\s*\()(?:unserialize\s*\((?![^\n]*allowed_classes)[^\n;]*(?:\$[A-Za-z_]|php://input)|maybe_unserialize\s*\([^\n;]*(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|php://input))'''
|
||||
|
||||
# --- output encoding (echo) --------------------------------------------------------------------------------
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-xss-echo-input"
|
||||
title = "PHP echo of request input without HTML escaping"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-79"
|
||||
tags = ["injection"]
|
||||
status = "shadow"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 0.333
|
||||
version = "1"
|
||||
pattern = '''(?m)^(?![^\n]*(?:htmlspecialchars|htmlentities|esc_html|esc_attr|esc_url|esc_js|intval|absint|json_encode|urlencode)\s*\()[^\n]*?(\becho\b|\bprint\b|<\?=)(?:[^\n;]*?[.,])?\s*\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\[[^\]\n]*\]\s*(?:[.,;]|\?>)'''
|
||||
|
||||
# --- path / inclusion (filesystem, file_inclusion) ---------------------------------------------------------
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-file-inclusion-input"
|
||||
title = "PHP include/require of a path from request input"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-98"
|
||||
tags = ["filesystem_entry"]
|
||||
status = "enabled"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 0.5
|
||||
version = "1"
|
||||
pattern = '''(?m)^(?![^\n]*\b(?:basename|realpath)\s*\()[^\n]*?(?<![\w'"$>:-])(include|include_once|require|require_once)\b(?!['"\]])\s*\(?[^\n;]*\$_(?:GET|POST|REQUEST|COOKIE)\b'''
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-path-input"
|
||||
title = "PHP filesystem call on a path from request input"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-22"
|
||||
tags = ["filesystem_entry"]
|
||||
status = "enabled"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 1.0
|
||||
version = "1"
|
||||
pattern = '''(?m)^(?![^\n]*\b(?:basename|realpath)\s*\()[^\n]*?(?<![\w>:$\\])(fopen|file_get_contents|file_put_contents|readfile|unlink|copy|rename|scandir|opendir|mkdir|rmdir|move_uploaded_file)\s*\([^\n;]*\$_(?:GET|POST|REQUEST|COOKIE)\b'''
|
||||
|
||||
# --- untrusted destination (outbound_request, redirect) ----------------------------------------------------
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-ssrf-input"
|
||||
title = "PHP outbound request to a URL from request input"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-918"
|
||||
tags = ["network_entry"]
|
||||
status = "enabled"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 1.0
|
||||
version = "1"
|
||||
pattern = '''(?<![\w>:$\\])(curl_init|curl_setopt|fsockopen|stream_socket_client|get_headers)\s*\([^\n;]*\$_(?:GET|POST|REQUEST|COOKIE)\b'''
|
||||
|
||||
[[rule]]
|
||||
rule_id = "php-open-redirect"
|
||||
title = "PHP Location header from request input"
|
||||
languages = ["php"]
|
||||
cwe = "CWE-601"
|
||||
tags = ["injection"]
|
||||
status = "enabled"
|
||||
min_evidence_level = "static_corroboration"
|
||||
precision_estimate = 1.0
|
||||
version = "1"
|
||||
pattern = '''(?<![\w>:$\\])(header)\s*\(\s*["']Location:[^\n;]*(?:\$_(?:GET|POST|REQUEST|COOKIE)\b|HTTP_REFERER)'''
|
||||
Reference in New Issue
Block a user