feat(model): one finding per sink site, and WordPress's registration model

contributor-scan 5.4. Both halves, and the second found a frontend blocker.

**One verdict per region was right for a pair and wrong for a file.** A
pair is one labelled function with one bug, so one answer is correct. A
region spanning a PHP file is not: the probe's SQL injection on line 5 and
command injection on line 13 produced ONE injection verdict, `system` won
it on flow length, and the SQL injection went unreported by construction
rather than by any failure of the analysis.

`taint.verdicts()` now returns one verdict per distinct sink site,
strongest first, and its first element is exactly what `verdict()`
returns -- so the pair path and every committed measurement resting on it
are unchanged, and the gates are byte-identical. Both entry points share
`_usable_flows` so neither can drift from the other.

  php probe   2 of 3 -> 3 of 3
  vampi       6 entailed, unchanged, now with lines on access-control
              findings from task 2.7 (users.py:187, books.py:51 -- the two
              documented BOLAs at their exact lines)
  libpng      1 -> 2 entailed; the second was hidden by the collapse.
              Neither is validated and both are in contrib/.

**WordPress registers handlers rather than decorating them, and the hook
name is the declaration.** `wp_ajax_nopriv_*` is WordPress saying
logged-out callers reach this, so it is public; `wp_ajax_*` fires only for
logged-in ones; `permission_callback => __return_true` declares a REST
route open. That is the declared-provenance signal task 2.8 needs,
arriving from a second framework. Plus every top-level PHP function as its
own region, without which a file is one region however many functions it
holds: 1 file-level region became 10 function-scoped ones.

**And a blocker, recorded as 5.5.** php2cpg emits a CPG containing only
ONE PHP file, the last alphabetically. Reproduced twice on independent
samples; both files parse cleanly under `php -l` and under PHP-Parser
directly, and joern-parse reports success either way. php2cpg logs "Found
warning in PHP-Parser JSON output" naming the survivor, and that warning is
PHP-Parser's own `====> File X:` banner appearing in what php2cpg expects
to be pure JSON.

Not isolated yet: this machine has no PHP interpreter, so php2cpg was
driven through a container shim. The banner comes from PHP-Parser rather
than the shim, which makes a shim-only cause unlikely but not excluded, and
rerunning with the native php-cli in the shipped image is the first step.
It blocks WordPress outright -- a plugin is many files, and a scan that
silently covers one of them and reports success is the quiet failure Req
5.6 was approved for.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-09 14:30:48 +02:00
co-authored by Claude Opus 5
parent 61811512a8
commit 0ffa99b5e6
7 changed files with 317 additions and 24 deletions
+41
View File
@@ -261,3 +261,44 @@ def test_entry_points_name_handlers_registered_by_call_or_convention(tmp_path: P
assert routes[("api_views/books.py", "delete")].access_level == "public"
body = routes[("api_views/books.py", "get_by_title")]
assert body.line == 4 and body.end_line == 5 # the record spans the handler, not the registration line
def test_wordpress_hooks_declare_their_access(tmp_path: Path) -> None:
"""contributor-scan 5.4. WordPress registers handlers rather than decorating them, and the hook name is
the declaration: `wp_ajax_nopriv_*` is WordPress saying logged-out callers reach this."""
from openultrasast.mapping import analyze_entry_points
from openultrasast.preprocess import preprocess_repository
(tmp_path / "plugin.php").write_text(
"<?php\n"
"add_action('wp_ajax_nopriv_fetch', 'mp_fetch');\n"
"add_action('wp_ajax_save', 'mp_save');\n"
"add_shortcode('box', 'mp_box');\n"
"function mp_fetch() { echo $_GET['id']; }\n"
"function mp_save() { echo $_POST['t']; }\n"
"function mp_box($a) { echo $a; }\n"
"function mp_helper($x) { return intval($x); }\n"
)
_, targets = preprocess_repository(tmp_path)
by_name = {entry.function_name: entry for entry in analyze_entry_points(tmp_path, targets)}
assert by_name["mp_fetch"].access_level == "public", "nopriv is declared unauthenticated"
assert by_name["mp_save"].access_level == "authenticated", "wp_ajax_ fires only for logged-in callers"
assert by_name["mp_box"].access_level == "public", "a shortcode renders in page content"
assert by_name["mp_helper"].access_level == "review-required", "a plain function is not a declared endpoint"
def test_every_php_function_becomes_a_region(tmp_path: Path) -> None:
"""Without this a PHP file is ONE region however many functions it holds, and a region that spans a file
attributes every finding to the file rather than to the function that holds it."""
from openultrasast.mapping import analyze_entry_points
from openultrasast.model.regions import regions_for
from openultrasast.preprocess import preprocess_repository
(tmp_path / "lib.php").write_text("<?php\nfunction a() { echo 1; }\nfunction b() { echo 2; }\n")
_, targets = preprocess_repository(tmp_path)
regions = regions_for(analyze_entry_points(tmp_path, targets), targets)
assert {region.function for region in regions} >= {"a", "b"}
+36
View File
@@ -463,3 +463,39 @@ def test_only_a_length_sink_can_be_discharged_by_a_bound() -> None:
assert not taint_specs(language="python")["injection"].bounded_sinks
assert not taint_specs(language="c")["injection"].bounded_sinks
assert taint_specs(language="c")["memory"].bounded_sinks
def test_a_region_reports_every_sink_site_not_just_the_strongest() -> None:
"""contributor-scan 5.4. One verdict per region was right for a pair and wrong for a region spanning a
file: a PHP file with SQL injection on one line and command injection on another got ONE injection
verdict, and `system` won it on flow length, so the SQL injection went unreported by construction."""
from openultrasast.cpg.backend import CpgResult
from openultrasast.model.specs import taint_specs
from openultrasast.model.taint import verdict, verdicts
def row(sink, line, length): # type: ignore[no-untyped-def]
return {
"sink": sink,
"sinkLine": str(line),
"sinkMethod": "handler",
"sinkFile": "app.php",
"source": '$_GET["x"]',
"sanitized": False,
"length": length,
"sinkArity": 2,
"sinkArg0Literal": False,
"inLabeledScope": True,
"bounded": False,
"bound": "",
}
rows = [row("mysqli_query($c, $sql)", 6, 5), row("system($cmd)", 13, 1)]
cpg = CpgResult(cpg_path=Path("c.bin"), run=lambda q, p: rows)
spec = taint_specs(language="php")["injection"]
every = verdicts(cpg, spec, function="", file="app.php")
assert len(every) == 2, "two sink sites are two findings"
assert {v.location for v in every} == {"app.php:6:handler", "app.php:13:handler"}
# The pair path is unchanged: the strongest is still what `verdict` returns.
assert every[0].location == verdict(cpg, spec, function="", file="app.php").location