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
+27
View File
@@ -72,6 +72,14 @@ class DispatchFact:
register: tuple[str, ...]
apply: tuple[str, ...]
language: str
# What an apply call RETURNS. `returns` names the apply calls whose result is the dispatched value, and
# `value_arg` is that value's argument position (1-based, receiver excluded): WordPress's
# `apply_filters('hook', $value, ...$context)` returns the filtered `$value`, and its context arguments
# never become the result. An apply call not named in `returns` returns nothing a flow can carry
# (`do_action`). Without this the engine let EVERY argument reach the return: PMPro's random order code
# came out "tainted" because `$this` was passed as filter context. Empty keeps the engine's default.
returns: tuple[str, ...] = ()
value_arg: int = 0
@dataclass(frozen=True)
@@ -164,11 +172,30 @@ def _dispatches(value: object, language: str, path: Path) -> list[DispatchFact]:
register=_strings(item.get("register"), "register", path),
apply=_strings(item.get("apply"), "apply", path),
language=language,
returns=_returning(item, path),
value_arg=_value_arg(item, path),
)
)
return facts
def _returning(item: dict[str, object], path: Path) -> tuple[str, ...]:
returns = _strings(item.get("returns", []), "returns", path)
unknown = set(returns) - set(_strings(item.get("apply"), "apply", path))
if unknown:
raise FactLoadError(f"dispatch {item.get('id')!r} in {path}: returns names calls that are not apply calls: {sorted(unknown)}")
return returns
def _value_arg(item: dict[str, object], path: Path) -> int:
value = item.get("value_arg", 0)
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise FactLoadError(f"dispatch {item.get('id')!r} in {path}: value_arg must be a non-negative integer")
if item.get("returns") and value < 1:
raise FactLoadError(f"dispatch {item.get('id')!r} in {path}: returns needs the value's argument position")
return value
def _layouts(value: object, language: str, path: Path) -> list[LayoutFact]:
facts: list[LayoutFact] = []
for item in _items(value):