feat(taint): project-local sink wrappers are sinks where they are called

Real code rarely calls the driver where it builds the query. YesWiki builds its
INSERT in `FormManager::create($data)` and runs it through
`$this->dbService->query($query)`, whose body is the `mysqli_query` the facts name.
The only sink the facts knew sat in a helper that no request reached directly.

A method whose parameter feeds a family sink is now a sink for that argument,
reported at the call site. The summary is structural, to stay cheap and
deterministic. It is not a flow query per parameter, which would repeat at every
engine start of a portioned scan. The parameter must reach the sink call's
argument directly or through at most two local assignments, with at least one
occurrence outside every cleansing call. It iterates up to three levels, so a
wrapper of a wrapper counts.

Calls resolve by name, because an injected receiver has no type the frontend can
see. A name therefore counts only if every project method with that name is a
wrapper. At a wrapper call, only the summarised arguments are asked.

Two Joern-gated tests cover:
- a request value into a second-level wrapper, reported at the controller's call;
- direct use, a literal, and an `intval`-casting helper, which are not wrappers;
- a harmless same-named method, which disqualifies the name.
Both fail with their part removed.

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-26 07:55:24 +02:00
co-authored by Claude Opus 5.5
parent 64ef488ce4
commit 54c9dfcd44
2 changed files with 146 additions and 2 deletions
+53
View File
@@ -1417,3 +1417,56 @@ def test_an_allowlist_check_guards_its_branch_and_its_ternary_arm(tmp_path: Path
payload = _taint_rows(tmp_path, "php2cpg", {"app.php": app}, {n: {**common, "function": n} for n in names})
reported = {n for n in names if _unsanitized(payload, n)}
assert reported == {"wrong_arm", "else_branch", "stored_in_else", "haystack"}, {n: payload[n] for n in names}
_WRAPPED_PHP = (
"<?php\n"
"class DbService { public $link;\n"
" function query( $query ) { return mysqli_query( $this->link, $query ); }\n"
" function number( $n ) { return mysqli_query( $this->link, 'SELECT ' . intval( $n ) ); } }\n"
"class FormManager { public $dbService;\n"
" function create( $data ) { $query = 'INSERT INTO t VALUES (' . $data['id'] . ')';\n"
" return $this->dbService->query( $query ); } }\n"
"class FormController { public $formManager; public $dbService;\n"
" function displayAll() { $value = $_POST['form']; return $this->formManager->create( $value ); }\n"
" function direct() { return $this->dbService->query( 'SELECT ' . $_POST['q'] ); }\n"
" function literal() { return $this->dbService->query( 'SELECT 1' ); }\n"
" function cast() { return $this->dbService->number( $_POST['n'] ); } }\n"
)
def _wrapped_requests(spec, names):
common = {
"sources": ",".join(spec.sources),
"sinks": ",".join(spec.sinks),
"sanitizers": ",".join(spec.sanitizers),
"quotedSanitizers": ",".join(spec.quoted_sanitizers),
"file": "app.php",
}
return {n: {**common, "function": n} for n in names}
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
def test_a_project_sink_wrapper_is_a_sink_where_it_is_called(tmp_path: Path) -> None:
"""YesWiki runs its INSERT through `$this->dbService->query($query)`, whose body is the `mysqli_query` the facts
name, and builds it in `FormManager::create($data)`. Neither receiver has a type the frontend can see. The
request value is reported where it enters a wrapper -- two levels up -- and not where it cannot."""
from openultrasast.model.specs import taint_specs
spec = taint_specs(language="php")["injection"]
names = ("displayAll", "direct", "literal", "cast")
payload = _taint_rows(tmp_path, "php2cpg", {"app.php": _WRAPPED_PHP}, _wrapped_requests(spec, names))
reported = {n: [r for r in _unsanitized(payload, n) if r.get("sinkMethod") == n] for n in names}
assert {n for n in names if reported[n]} == {"displayAll", "direct"}, {n: payload[n] for n in names}
assert "create" in str(reported["displayAll"][0]["sink"]), reported["displayAll"]
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
def test_a_wrapper_name_shared_with_a_harmless_method_is_not_a_sink(tmp_path: Path) -> None:
"""Calls resolve by name, so a name is a sink only if every project method so named is a wrapper."""
from openultrasast.model.specs import taint_specs
spec = taint_specs(language="php")["injection"]
code = _WRAPPED_PHP + "class Cache { function query( $key ) { return strlen( $key ); } }\n"
payload = _taint_rows(tmp_path, "php2cpg", {"app.php": code}, _wrapped_requests(spec, ("direct",)))
assert not [r for r in _unsanitized(payload, "direct") if r.get("sinkMethod") == "direct"], payload["direct"]