mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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:
co-authored by
Claude Opus 5
parent
61811512a8
commit
0ffa99b5e6
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user