mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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:
co-authored by
Claude Opus 5.5
parent
64ef488ce4
commit
54c9dfcd44
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user