mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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:
co-authored by
Claude Opus 5
parent
3bd844055c
commit
2c5fba55ef
@@ -457,6 +457,7 @@ Preserve all completed task evidence below.
|
|||||||
- _Measured 2026-09-17, PHP transfer NOT established._ All three comparisons exhausted a 3000-second deadline with a 1200-second query ceiling and produced no artifact, so there is no target evidence at all. The engine reports `cpg batch taint failed` twice per case on the 1,274-file PMPro tree: first an exception whose trace runs through ujson array transformation, then a timeout on the retry. Budgets tried: 900 s, 1200 s and 3000 s deadlines against 300 s and 1200 s query ceilings. Pins, source receipts and the declared target are all correct, so the question was asked properly and the engine could not answer it. This is a runtime blocker on a large repository, not evidence about whether the design transfers to PHP. Evidence: `benchmarks/measurements/2026-09-17-m3-php-transfer.json`.
|
- _Measured 2026-09-17, PHP transfer NOT established._ All three comparisons exhausted a 3000-second deadline with a 1200-second query ceiling and produced no artifact, so there is no target evidence at all. The engine reports `cpg batch taint failed` twice per case on the 1,274-file PMPro tree: first an exception whose trace runs through ujson array transformation, then a timeout on the retry. Budgets tried: 900 s, 1200 s and 3000 s deadlines against 300 s and 1200 s query ceilings. Pins, source receipts and the declared target are all correct, so the question was asked properly and the engine could not answer it. This is a runtime blocker on a large repository, not evidence about whether the design transfers to PHP. Evidence: `benchmarks/measurements/2026-09-17-m3-php-transfer.json`.
|
||||||
- _Two harness defects found and fixed on the way, each of which had made an earlier run look like a transfer failure:_ the manifest labels a case by what it is while the id says what the comparison does, and filtering on one while keying expectations by the other ran a single case; and the per-query ceiling is taken as the minimum with the remaining transaction time, so raising only the deadline could never have helped.
|
- _Two harness defects found and fixed on the way, each of which had made an earlier run look like a transfer failure:_ the manifest labels a case by what it is while the id says what the comparison does, and filtering on one while keying expectations by the other ran a single case; and the per-query ceiling is taken as the minimum with the remaining transaction time, so raising only the deadline could never have helped.
|
||||||
- _Retried 2026-09-18 with batch splitting, still not established._ A batch that fails whole is now retried in eight parts, which a probe made look promising: the evidence pass answers all 4,425 PMPro regions in one batch in 29.7 s with a 681 KB response, and cost is fixed rather than per request, so neither count nor size is near a limit. That probe sent `evidenceOnly=true` and therefore measured the structural pass, not the failing one; recorded because those numbers would otherwise read as evidence that PMPro's taint work is cheap. With a 4500-second deadline and a 900-second query ceiling the run was stopped after 26 minutes with no case completed. Splitting also makes an unrecoverable batch slower to abandon, since each part burns its own share of the ceiling.
|
- _Retried 2026-09-18 with batch splitting, still not established._ A batch that fails whole is now retried in eight parts, which a probe made look promising: the evidence pass answers all 4,425 PMPro regions in one batch in 29.7 s with a 681 KB response, and cost is fixed rather than per request, so neither count nor size is near a limit. That probe sent `evidenceOnly=true` and therefore measured the structural pass, not the failing one; recorded because those numbers would otherwise read as evidence that PMPro's taint work is cheap. With a 4500-second deadline and a 900-second query ceiling the run was stopped after 26 minutes with no case completed. Splitting also makes an unrecoverable batch slower to abandon, since each part burns its own share of the ceiling.
|
||||||
|
- _Correction 2026-09-20: the malformed-CPG bug is fixed and was never what these runs were hitting._ 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 is the version the image has pinned since before any measurement here. Verified today on the real trigger: 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. So what remains under this task is a RUNTIME failure and must not be re-diagnosed as the graph bug.
|
||||||
- **Blocker still unidentified; investigation paused 2026-09-19 to return to vulnerability work.** Six hypotheses were measured and eliminated: batch size (17,700 requests answer in 44 s with a 2.6 MB response), dataflow cost (50 execution requests in 24 s), call depth 3 with parameter sources (24.7 s for 20), argument length (a 16 KB hook table against a 128 KB cap), hook callback resolution (1 s difference with 233 hooks), and build or sharding (79 s, no shards). Preparation measures 273.5 s. Every reproduction of the failing 22,125-request batch outside the push path answers in under a minute, and the difference from the production invocation is not known. The exception has never been read, because the backend logged a stack trace's tail and the corrected extractor reports its head, which on this engine is JVM startup noise. Evidence: `benchmarks/measurements/2026-09-19-php-blocker-open.json`.
|
- **Blocker still unidentified; investigation paused 2026-09-19 to return to vulnerability work.** Six hypotheses were measured and eliminated: batch size (17,700 requests answer in 44 s with a 2.6 MB response), dataflow cost (50 execution requests in 24 s), call depth 3 with parameter sources (24.7 s for 20), argument length (a 16 KB hook table against a 128 KB cap), hook callback resolution (1 s difference with 233 hooks), and build or sharding (79 s, no shards). Preparation measures 273.5 s. Every reproduction of the failing 22,125-request batch outside the push path answers in under a minute, and the difference from the production invocation is not known. The exception has never been read, because the backend logged a stack trace's tail and the corrected extractor reports its head, which on this engine is JVM startup noise. Evidence: `benchmarks/measurements/2026-09-19-php-blocker-open.json`.
|
||||||
- _Boundary: Evaluation harness, full-run integration_
|
- _Boundary: Evaluation harness, full-run integration_
|
||||||
- _Depends: 13.1_
|
- _Depends: 13.1_
|
||||||
|
|||||||
@@ -125,9 +125,17 @@ rule_ids = []
|
|||||||
# --- deserialization ---------------------------------------------------------------------------------
|
# --- deserialization ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
[[sink]]
|
[[sink]]
|
||||||
|
# `maybe_unserialize` is WordPress's own wrapper and the form every plugin in this project's corpus actually
|
||||||
|
# writes: across four plugins and 1,164 PHP files there is exactly one deserialization site, and it is spelled
|
||||||
|
# that way. It is not reached by the bare token, and correctly so -- the matcher is word-bounded, and the
|
||||||
|
# character before `unserialize` inside `maybe_unserialize` is a word character. A token that a repository
|
||||||
|
# never spells is a family that can never answer, which is what the 2026-09-19 census recorded for this one.
|
||||||
|
#
|
||||||
|
# It is a real entry point rather than a safe wrapper: `maybe_unserialize` calls `unserialize` whenever the
|
||||||
|
# string looks serialized, and only returns the value untouched when it does not.
|
||||||
id = "unserialize"
|
id = "unserialize"
|
||||||
cwe = "CWE-502"
|
cwe = "CWE-502"
|
||||||
calls = ["unserialize"]
|
calls = ["unserialize", "maybe_unserialize"]
|
||||||
rule_ids = []
|
rule_ids = []
|
||||||
|
|
||||||
# --- untrusted destination ---------------------------------------------------------------------------
|
# --- untrusted destination ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -516,3 +516,62 @@ def test_a_destructured_identity_discharges_what_it_reaches(tmp_path: Path) -> N
|
|||||||
leaked = answer("displayEverything")
|
leaked = answer("displayEverything")
|
||||||
assert leaked is not None, "a query that reads the whole collection was not reported"
|
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}"
|
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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user