fix(taint): a filter returns its value argument; a source is the access, not a call around it

The apply_filters class from the second PMPro adjudication (2 of 3
remaining false positives): `apply_filters('pmpro_random_code', $code,
$this)` returned whatever $this carried, and a report's `$type` reached SQL
only as an extra filter argument. Two causes, both fixed generally:

- The dispatch fact now says what an apply call RETURNS: `returns` names
  the calls whose result is the dispatched value and `value_arg` its
  position (WordPress: apply_filters/apply_filters_ref_array return
  argument 2; do_action returns nothing). A path through such a call must
  ENTER by that argument -- the first of the run of the call's arguments
  before it, because the engine hops between sibling arguments
  ($_GET["c"] -> md5("x") -> apply_filters). Loaded and validated as data;
  the query knows no framework.
- A call was a source when the pattern appeared anywhere in its text, so
  `apply_filters(..., $_GET["c"])` and `sanitize_text_field($_POST[..])`
  were sources themselves -- the "a token matches the thing it names"
  invariant. A call is now a source when the pattern names its callee,
  receiver or the access it is; the inner access stays a source.

Controls: dispatch facts load/validate; a real-Joern case with request
data as filter context (quiet) and as the filtered value (found). 1,453
host and 18 query tests pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-24 08:36:28 +02:00
co-authored by Claude Opus 5.5
parent f1e43d3646
commit 7b8ff3cf44
8 changed files with 182 additions and 6 deletions
+36
View File
@@ -0,0 +1,36 @@
"""A registry read returns its VALUE argument, not its context: a fact, loaded and checked."""
import pytest
from openultrasast.model.specs import taint_specs
from openultrasast.semantic.facts import FactLoadError, load_facts
def _write(tmp_path, dispatch: str) -> None:
(tmp_path / "php.toml").write_text('language = "php"\n[[source]]\nid = "g"\npatterns = ["$_GET"]\n' + dispatch)
def test_the_shipped_facts_say_which_argument_a_filter_returns() -> None:
spec = taint_specs(language="php")["injection"]
assert "apply_filters:2" in spec.dispatch_value and "do_action:0" in spec.dispatch_value
def test_a_dispatch_row_declares_returns_with_a_position(tmp_path) -> None:
row = '[[dispatch]]\nid = "hooks"\nregister = ["add_filter"]\napply = ["apply_filters", "do_action"]\n'
_write(tmp_path, row + 'returns = ["apply_filters"]\nvalue_arg = 2\n')
(fact,) = load_facts(tmp_path).dispatches
assert fact.returns == ("apply_filters",) and fact.value_arg == 2
@pytest.mark.parametrize(
"row",
[
'returns = ["apply_filters"]\n', # a returning call without the value's position
'returns = ["not_an_apply_call"]\nvalue_arg = 2\n',
"value_arg = -1\n",
],
)
def test_a_malformed_dispatch_row_is_refused(tmp_path, row: str) -> None:
_write(tmp_path, '[[dispatch]]\nid = "hooks"\nregister = ["add_filter"]\napply = ["apply_filters"]\n' + row)
with pytest.raises(FactLoadError):
load_facts(tmp_path)