mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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:
co-authored by
Claude Opus 5.5
parent
f1e43d3646
commit
7b8ff3cf44
@@ -745,3 +745,60 @@ def test_a_path_through_a_query_result_or_a_whole_object_is_not_an_injection(tmp
|
||||
assert lines["c"] == [], f"an argument into an object's method, its field read: {lines['c']}"
|
||||
assert lines["d"] == [18], f"a member of a decoded request value must still be found: {lines['d']}"
|
||||
assert lines["e"] == [20], f"the field that was written, read back, must still be found: {lines['e']}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
|
||||
def test_a_filter_returns_its_value_argument_not_its_context(tmp_path: Path) -> None:
|
||||
"""`apply_filters('h', $value, ...$context)` returns the filtered value. Request data passed as context
|
||||
made PMPro's random md5 order code an SQL injection; request data passed as the value must still flow."""
|
||||
from openultrasast.cpg.backend import extract_payload
|
||||
|
||||
frontend = __import__("shutil").which("php2cpg")
|
||||
if frontend is None:
|
||||
pytest.skip("php2cpg is not installed")
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
(src / "app.php").write_text(
|
||||
"<?php\n"
|
||||
"function context_handler() { global $wpdb; $code = apply_filters('h', md5('x'), $_GET['c']);\n"
|
||||
' return $wpdb->query("DELETE FROM t WHERE code = \'" . $code . "\'"); }\n'
|
||||
"function value_handler() { global $wpdb; $v = apply_filters('h', $_GET['v'], 'context');\n"
|
||||
' return $wpdb->query("DELETE FROM t WHERE code = \'" . $v . "\'"); }\n'
|
||||
)
|
||||
cpg = tmp_path / "cpg.bin"
|
||||
built = subprocess.run([frontend, str(src), "-o", str(cpg)], capture_output=True, text=True, timeout=600, check=False)
|
||||
if not cpg.is_file():
|
||||
pytest.skip(f"could not build a sample cpg: {(built.stderr or '')[-200:]}")
|
||||
common = {
|
||||
"sources": "$_GET",
|
||||
"sinks": "$wpdb->query",
|
||||
"file": "app.php",
|
||||
"dispatchApply": "apply_filters,do_action",
|
||||
"dispatchValue": "apply_filters:2,do_action:0",
|
||||
}
|
||||
requests = tmp_path / "requests.json"
|
||||
requests.write_text(
|
||||
json.dumps({"context": {**common, "function": "context_handler"}, "value": {**common, "function": "value_handler"}})
|
||||
)
|
||||
done = subprocess.run(
|
||||
[
|
||||
_joern() or "joern",
|
||||
"--script",
|
||||
str((QUERIES / "taint.sc").resolve()),
|
||||
"--param",
|
||||
f"cpgFile={cpg}",
|
||||
"--param",
|
||||
f"requestsFile={requests}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
check=False,
|
||||
cwd=str(tmp_path),
|
||||
)
|
||||
assert done.returncode == 0, (done.stderr or done.stdout or "")[-500:]
|
||||
payload = extract_payload(done.stdout or "")
|
||||
assert isinstance(payload, dict)
|
||||
flows = {rid: [r for r in payload[rid] if isinstance(r, dict) and r.get("sourceKind")] for rid in ("context", "value")}
|
||||
assert not flows["context"], f"filter context became its result: {flows['context']}"
|
||||
assert flows["value"], "the filtered value no longer carries request data"
|
||||
|
||||
Reference in New Issue
Block a user