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
+9
View File
@@ -115,6 +115,9 @@ class TaintSpec:
# The calls that read a value back out of a string-keyed registry (`apply_filters`, `do_action`). Facts,
# not constants, so a second framework is a row in a TOML rather than an edit to the query.
dispatch_apply: tuple[str, ...] = ()
# Which argument of each apply call its RESULT comes from, as `call:position`; position 0 means the call
# returns nothing a flow can carry. Only calls a fact describes appear here.
dispatch_value: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -243,6 +246,11 @@ def taint_specs(
return bool(getattr(fact, "parameterized", False)) or getattr(fact, "literal_format_arg", None) is not None
dispatch_apply = tuple(sorted({call for fact in scoped.dispatches for call in fact.apply}))
dispatch_value = tuple(
sorted(
{f"{call}:{fact.value_arg if call in fact.returns else 0}" for fact in scoped.dispatches if fact.returns for call in fact.apply}
)
)
sanitizers = tuple(sorted({call for fact in scoped.sanitizers if not _is_shape(fact) for call in fact.calls}))
safe_shapes = tuple(sorted({call for fact in scoped.sanitizers if _is_shape(fact) for call in fact.calls}))
by_family: dict[str, set[str]] = {}
@@ -266,6 +274,7 @@ def taint_specs(
bounded_sinks=tuple(sorted(bounded.get(family_id, ()))),
safe_shape_sinks=safe_shapes,
dispatch_apply=dispatch_apply,
dispatch_value=dispatch_value,
)
for family_id, calls in sorted(by_family.items())
}
+1
View File
@@ -59,6 +59,7 @@ def request_params(
# The registry's own vocabulary, from the fact tables. Without it the query would have to know what
# `apply_filters` is, which makes a general mechanism into a WordPress feature.
"dispatchApply": spec.dispatch_apply,
"dispatchValue": spec.dispatch_value,
}