fix(semantic-facts): reach the deserialization form WordPress actually writes

Across four WordPress plugins and 1,164 PHP files there is exactly one deserialization site, and
it is spelled maybe_unserialize. The bare token never reached it, and correctly so: the sink
matcher is word-bounded and the character before unserialize inside maybe_unserialize is a word
character. A token a repository never spells is a family that can never answer, which is what the
2026-09-19 census recorded for this one. maybe_unserialize is a real entry point rather than a
safe wrapper: it calls unserialize whenever the string looks serialized.

Also adds the prototype control. That family had never established anything on any subject, and
its only census report was the word `set` inside a comment. No repository available to this
project contains the mechanism: the only first-party matches on the Node subject are
app.set("view engine", "html") and its sibling, Express settings whose arguments are literals. So
the control demonstrates what the family can do, on a merge of the request body into an object,
and asserts the Express setting is not reported alongside it.

Corrects the PHP record under task 13.2. joernio/joern#6281, this project's own report, is a PHP
`global` inside a closure producing a node with two AST parents, which made the dataflow overlay
throw and cost the whole graph. It was filed against 4.0.623 and fixed by #6270 in v4.0.625,
which the image has pinned since before any measurement recorded here. Verified today on the real
trigger rather than on a transcription of the repro: PMPro's includes/fields.php, the file the
original bisection found, builds and applies the default overlays cleanly on 4.0.625 and on
4.0.631 alike. What remains under that task is a runtime failure and must not be re-diagnosed as
the graph bug.

1,417 tests pass, eighteen skipped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-20 18:09:57 +02:00
co-authored by Claude Opus 5
parent 3bd844055c
commit 2c5fba55ef
3 changed files with 69 additions and 1 deletions
+59
View File
@@ -516,3 +516,62 @@ def test_a_destructured_identity_discharges_what_it_reaches(tmp_path: Path) -> N
leaked = answer("displayEverything")
assert leaked is not None, "a query that reads the whole collection was not reported"
assert leaked.rung is Rung.CORROBORATED, f"{leaked.rung} from {leaked.witness}"
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
def test_prototype_pollution_is_established_on_the_shape_it_declares(tmp_path: Path) -> None:
"""The family had never established anything, on any subject, so this is what it can do.
The 2026-09-19 census recorded prototype pollution asked on one repository, where its only report was the
word `set` inside a comment. No repository available to this project contains the mechanism: the only
first-party matches on the Node subject are `app.set("view engine", "html")` and its sibling, which are
Express settings whose arguments are literals. So the family's ability to answer is demonstrated here, on
a merge of the request body into an object, and the Express setting is asserted alongside it.
"""
from openultrasast.cpg.backend import extract_payload
from openultrasast.model.specs import taint_specs
frontend = __import__("shutil").which("jssrc2cpg")
if frontend is None:
pytest.skip("jssrc2cpg is not installed")
sinks = taint_specs(language="javascript")["prototype"].sinks
source = tmp_path / "merge.js"
source.write_text(
'const _ = require("lodash");\n'
"\n"
"function handler(req, res, app) {\n"
' app.set("view engine", "html");\n'
" const options = {};\n"
" _.merge(options, req.body);\n"
" return res.end();\n"
"}\n"
)
cpg = tmp_path / "cpg.bin"
built = subprocess.run([frontend, str(tmp_path), "-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:]}")
requests = tmp_path / "requests.json"
requests.write_text(json.dumps({"0": {"sources": "req.body", "sinks": ",".join(sinks), "function": "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,
)
assert done.returncode == 0, f"taint.sc did not run: {(done.stderr or done.stdout or '')[-500:]}"
payload = extract_payload(done.stdout or "")
assert payload is not None, "taint.sc produced no parseable payload"
flows = [row for row in (payload.get("0") or []) if row.get("sink")]
assert flows, "a request body merged into an object was not reported"
assert all("merge" in str(row["sink"]) for row in flows), f"the Express setting was reported: {flows}"