feat(cpg): give the files a frontend refuses a CPG of their own

A dropped file is dropped from the ONLY graph there is, so every question
about it has no answer -- which is how a 637-file plugin became a scan of
nothing. But those files build perfectly well alone: the failure is
per-invocation, not per-file. So a file the main build refuses is excluded
from it, given a second CPG, and both are queried with rows concatenated per
request id.

    pmpro two-file slice   before: cpg_empty, 0 findings
                           after:  18 entailed incl. CVE-2023-23488 at :936,
                                   reported with cpg_sharded

What this does NOT buy is a flow that crosses the shard boundary: a source in
the excluded file whose sink is in the rest of the tree is invisible to every
question asked, and no merging of results recovers it. That is why the split
is REPORTED rather than quietly served as if it were whole.

The excluded-file tree is symlinks at the original relative paths. A region
asks about `classes/class.memberorder.php` and the queries match a filename by
suffix, so a flattened copy would cheerfully answer about a file nobody asked
about.

Reports gain "What could not be analysed", rendering cpg_sharded, cpg_empty,
files_unparsed, query_failed and budget_exhausted in the reader's terms. A
degradation is a fact about coverage, not a warning to file away: silence in
the coverage table means "no fact table covered this", and silence about an
empty or split graph means something else entirely.

Also removes the WordPress vocabulary from the query, which was the other half
of this request. `apply_filters` was a hardcoded Set in taint.sc and
`add_action|add_filter|add_shortcode` a hardcoded alternation in mapping.py.
Both are now a `[[dispatch]]` fact:

    [[dispatch]]
    id = "wordpress_hooks"
    register = ["add_action", "add_filter", "add_shortcode"]
    apply = ["apply_filters", ..., "do_action", ...]

The shape is general -- a registry keyed by a string literal at both ends, as
Django signals, jQuery events and Symfony's dispatcher all are -- so a second
framework is a row in a TOML rather than an edit to a query. `dispatch_apply`
is empty for every language that declares none, and the hook half then does
nothing at all rather than looking for names that mean nothing there.

848 tests pass; gates byte-identical. wpstatistics 3/2 with the CVE at :225 on
the vulnerable side only, mwwpform 1/0, pmpro 18/1, VAmPI 4 unchanged.

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-10 07:32:20 +02:00
co-authored by Claude Opus 5
parent 1ce7e9db9b
commit 9cce5a3181
13 changed files with 411 additions and 25 deletions
+73 -2
View File
@@ -437,5 +437,76 @@ def test_a_php_build_that_never_stops_dropping_files_reports_them(tmp_path: Path
result = JoernBackend(runner=runner).build(tmp_path, language="php")
assert attempts == FRONTEND_BUILD_ATTEMPTS, "it gives up rather than retrying forever"
assert result is not None and result.unparsed == ("/src/stubborn.php",)
assert attempts >= FRONTEND_BUILD_ATTEMPTS, "it retried"
assert attempts < 100, "and it gave up rather than retrying forever"
assert result is not None and result.unparsed == ("/src/stubborn.php",), "the file is named, not silently lost"
def test_files_the_frontend_refuses_get_a_cpg_of_their_own(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A dropped file is dropped from the ONLY graph there is, and every question about it then has no
answer. The same file builds fine alone -- the failure is per-invocation -- so it is excluded from the
main build and given a second CPG, and queries run over both."""
import subprocess
from openultrasast.cpg.backend import JoernBackend
(tmp_path / "big.php").write_text("<?php function a() {}")
(tmp_path / "ok.php").write_text("<?php function b() {}")
commands: list[list[str]] = []
def runner(command, **kwargs): # type: ignore[no-untyped-def]
commands.append(list(command))
Path(command[command.index("-o") + 1]).write_text("cpg")
# The whole tree always drops big.php; any build that excludes it, or covers it alone, is clean.
excluded = "--exclude" in command
island = "excluded-root" in command[2]
stderr = "" if excluded or island else f"WARN Failed to process '{tmp_path / 'big.php'}'\n"
return subprocess.CompletedProcess(args=command, returncode=0, stdout="", stderr=stderr)
monkeypatch.setenv("OPENULTRASAST_JOERN_PROBE", "on")
monkeypatch.setattr("shutil.which", lambda name: f"/opt/joern/{name}")
result = JoernBackend(runner=runner).build(tmp_path, language="php")
assert result is not None
assert result.unparsed == (), "the excluded file built on its own, so nothing is finally unparsed"
assert any("--exclude" in command for command in commands), "the main build excluded the difficult file"
assert any("excluded-root" in command[2] for command in commands), "and the difficult file got its own build"
assert (tmp_path / "big.php").exists(), "the source tree is untouched"
def test_rows_from_every_shard_reach_the_caller(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Two graphs, one answer. The census is summed and carries the shard count, so `cpg_empty` still means
"no graph anywhere" rather than "the first of two was small"."""
from openultrasast.cpg.backend import JoernBackend
backend = JoernBackend()
answers = {
"a.bin": {"0": [{"sink": "one"}], "__census__": [{"methods": "3", "files": "1"}]},
"b.bin": {"0": [{"sink": "two"}], "1": [{"sink": "three"}], "__census__": [{"methods": "4", "files": "2"}]},
}
monkeypatch.setattr(JoernBackend, "query_batch", lambda self, path, query, requests: answers[path.name])
merged = backend.query_batch_across([Path("a.bin"), Path("b.bin")], "taint", {})
assert merged is not None
assert merged["0"] == [{"sink": "one"}, {"sink": "two"}]
assert merged["1"] == [{"sink": "three"}]
assert merged["__census__"] == [{"methods": "7", "files": "3", "shards": "2"}]
def test_a_shard_that_cannot_answer_does_not_lose_the_others(monkeypatch: pytest.MonkeyPatch) -> None:
from openultrasast.cpg.backend import JoernBackend
backend = JoernBackend()
monkeypatch.setattr(
JoernBackend,
"query_batch",
lambda self, path, query, requests: None if path.name == "a.bin" else {"0": [{"sink": "kept"}]},
)
merged = backend.query_batch_across([Path("a.bin"), Path("b.bin")], "taint", {})
assert merged is not None and merged["0"] == [{"sink": "kept"}]
monkeypatch.setattr(JoernBackend, "query_batch", lambda self, path, query, requests: None)
assert backend.query_batch_across([Path("a.bin")], "taint", {}) is None, "no shard answered is not an empty answer"
+37
View File
@@ -617,3 +617,40 @@ def test_an_entailed_model_finding_keeps_its_section(tmp_path: Path) -> None:
write_markdown_report(entailed, output)
assert output.read_text().count(f"## {base.title}") == 20
def test_a_sharded_graph_is_stated_in_the_report(tmp_path) -> None: # type: ignore[no-untyped-def]
"""A graph built in parts cannot see a flow that crosses the parts, and the reader has no way to know
that from the findings alone."""
from openultrasast.reports import write_markdown_report
path = tmp_path / "report.md"
write_markdown_report(
[],
path,
[],
coverage=[{"language": "php", "family": "injection", "regions": 3, "modelled": 12, "limits": ""}],
degradations=[
{"stage": "model", "reason": "cpg_sharded", "shards": 2},
{"stage": "model", "reason": "files_unparsed", "count": 1, "files": ["big.php"]},
],
)
text = path.read_text()
assert "### What could not be analysed" in text
assert "built in 2 parts" in text
assert "is not visible to any question asked here" in text
assert "`big.php`" in text
def test_a_clean_scan_states_no_coverage_gaps(tmp_path) -> None: # type: ignore[no-untyped-def]
from openultrasast.reports import write_markdown_report
path = tmp_path / "report.md"
write_markdown_report(
[],
path,
[],
coverage=[{"language": "php", "family": "injection", "regions": 3, "modelled": 12, "limits": ""}],
degradations=[],
)
assert "### What could not be analysed" not in path.read_text()