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
+127 -11
View File
@@ -26,7 +26,7 @@ import re
import shutil
import subprocess
import tempfile
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol
@@ -193,15 +193,16 @@ class JoernBackend:
# A frontend whose failures are visible and retryable is worth more than one whose are not.
if language.lower() in _PREFER_FRONTEND:
retried = self._build_with_retries(root, cpg_path, scratch, language)
if retried is not None:
unparsed = retried
shards, unparsed = self._build_sharded(root, scratch, language)
if shards:
if unparsed:
logger.warning("the frontend could not parse %d file(s) under %s: %s", len(unparsed), root, ", ".join(unparsed[:5]))
if len(shards) > 1:
logger.info("querying %d cpg shards for %s; flows that cross them are not visible", len(shards), root)
return CpgResult(
cpg_path=cpg_path,
run=lambda query, params: self.query(cpg_path, query, params),
run_batch=lambda query, requests: self.query_batch(cpg_path, query, requests),
cpg_path=shards[0],
run=lambda query, params: self.query_across(shards, query, params),
run_batch=lambda query, requests: self.query_batch_across(shards, query, requests),
unparsed=unparsed,
)
command = [parse or "joern-parse", self._heap_flag(), str(root), "--output", str(cpg_path)]
@@ -223,7 +224,117 @@ class JoernBackend:
unparsed=unparsed,
)
def _build_with_retries(self, root: Path, cpg_path: Path, scratch: Path, language: str) -> tuple[str, ...] | None:
def _build_sharded(self, root: Path, scratch: Path, language: str) -> tuple[tuple[Path, ...], tuple[str, ...]]:
"""Build the tree, and give the files it refuses a CPG of their OWN rather than losing them.
A frontend that drops a file drops it from the only graph there is, and every question about that
file then has no answer -- which is how a 637-file plugin became a scan of nothing. But the same
files build perfectly well on their own: the failure is per-invocation, not per-file. So a file the
main build refuses is excluded from it and put in a second CPG, and the queries run over both.
What this does NOT buy is a flow that crosses the shard boundary. A source in the excluded file and
its sink in the rest of the tree is invisible, and no merging of results can recover it -- which is
why the driver reports the split rather than quietly serving a partial answer.
Returns the CPGs to query and the files that defeated even a shard of their own.
"""
main = scratch / "cpg.bin"
dropped = self._build_with_retries(root, main, scratch, language)
if dropped is None:
return (), ()
if not dropped:
return (main,), ()
# Second pass over the same tree, this time telling the frontend to leave the difficult files alone,
# so the rest of the repository is analysed instead of nothing being analysed.
remainder = self._build_with_retries(root, main, scratch, language, exclude=dropped)
shards: list[Path] = []
if remainder is not None and main.is_file():
shards.append(main)
island = self._island_root(root, scratch, dropped)
if island is None:
return tuple(shards), dropped
second = scratch / "cpg-excluded.bin"
still = self._build_with_retries(island, second, scratch, language)
if still is not None and second.is_file():
shards.append(second)
return tuple(shards), tuple(still)
return tuple(shards), dropped
def _island_root(self, root: Path, scratch: Path, files: Sequence[str]) -> Path | None:
"""A tree holding only ``files``, at their original relative paths.
The paths have to be preserved: a region asks about `classes/class.memberorder.php`, and the queries
match a method's filename by suffix, so a flattened copy would answer about a file nobody asked
about. Symlinks, because the alternative is copying a repository to analyse it.
"""
island = scratch / "excluded-root"
made = 0
for name in files:
source = Path(name)
if not source.is_absolute():
source = root / name
if not source.is_file():
continue
try:
relative = source.relative_to(root)
except ValueError:
relative = Path(source.name)
destination = island / relative
try:
destination.parent.mkdir(parents=True, exist_ok=True)
if not destination.exists():
destination.symlink_to(source)
made += 1
except OSError as exc: # noqa: PERF203 - one bad file must not lose the rest
logger.warning("could not stage %s for its own cpg: %s", source, exc)
return island if made else None
def query_across(self, shards: Sequence[Path], query: str, params: Mapping[str, object]) -> object | None:
"""One question over every shard, answers concatenated. ``None`` only when no shard could answer."""
merged: list[object] = []
answered = 0
for shard in shards:
rows = self.query(shard, query, params)
if rows is None:
continue
answered += 1
if isinstance(rows, list):
merged.extend(rows)
return merged if answered else None
def query_batch_across(
self, shards: Sequence[Path], query: str, requests: Mapping[str, Mapping[str, object]]
) -> dict[str, list[object]] | None:
"""The batch over every shard, rows concatenated per request id.
The census is summed rather than taken from one shard, and carries the shard count, because
`cpg_empty` has to mean "no graph anywhere" and not "the first of two graphs was small".
"""
merged: dict[str, list[object]] = {}
methods = 0
files = 0
answered = 0
for shard in shards:
one = self.query_batch(shard, query, requests)
if one is None:
continue
answered += 1
census = one.pop("__census__", None)
if census and isinstance(census[0], Mapping):
methods += int(str(census[0].get("methods", "0")) or 0)
files += int(str(census[0].get("files", "0")) or 0)
for rid, rows in one.items():
merged.setdefault(rid, []).extend(rows)
if not answered:
return None
merged["__census__"] = [{"methods": str(methods), "files": str(files), "shards": str(len(shards))}]
return merged
def _build_with_retries(
self, root: Path, cpg_path: Path, scratch: Path, language: str, exclude: Sequence[str] = ()
) -> tuple[str, ...] | None:
"""Build through the frontend, trying again while it reports files it could not parse.
Retrying is only sound because the failure is INTERMITTENT and the frontend says when it happened.
@@ -235,7 +346,7 @@ class JoernBackend:
"""
best: tuple[str, ...] | None = None
for attempt in range(FRONTEND_BUILD_ATTEMPTS):
unparsed = self._build_with_frontend(root, cpg_path, scratch, language)
unparsed = self._build_with_frontend(root, cpg_path, scratch, language, exclude)
if unparsed is None:
return best # the frontend is missing or failed outright; keep any earlier partial build
if not unparsed:
@@ -250,7 +361,9 @@ class JoernBackend:
)
return best
def _build_with_frontend(self, root: Path, cpg_path: Path, scratch: Path, language: str) -> tuple[str, ...] | None:
def _build_with_frontend(
self, root: Path, cpg_path: Path, scratch: Path, language: str, exclude: Sequence[str] = ()
) -> tuple[str, ...] | None:
"""Retry the build through the language frontend alone.
Returns the files the frontend dropped -- possibly none -- or ``None`` when there is nothing to retry
@@ -265,7 +378,10 @@ class JoernBackend:
if binary is None:
logger.warning("no frontend %s on PATH to retry the build for %s", frontend, root)
return None
completed = self._run([binary, self._heap_flag(), str(root), "-o", str(cpg_path)], timeout=self.build_timeout, cwd=scratch)
command = [binary, self._heap_flag(), str(root), "-o", str(cpg_path)]
for name in exclude:
command += ["--exclude", name]
completed = self._run(command, timeout=self.build_timeout, cwd=scratch)
if completed is None or completed.returncode != 0 or not cpg_path.is_file():
detail = (completed.stderr or completed.stdout or "")[-400:] if completed is not None else "timeout"
logger.warning("%s also failed for %s: %s", frontend, root, detail)
+10 -4
View File
@@ -49,6 +49,7 @@
boundedSinks: String = "",
parameterSources: String = "false",
hookCallbacks: String = "",
dispatchApply: String = "",
requests: String = "",
requestsFile: String = ""
) = {
@@ -68,6 +69,7 @@
sinksS: String,
sanitizersS: String,
hookCallbacksS: String,
dispatchApplyS: String,
functionS: String,
paramSrc: String,
fileS: String,
@@ -308,7 +310,10 @@
// The rule: an `apply_filters('H', ...)` call is a SOURCE when some callback registered for H returns
// data that an unsanitized flow reached. That is the same question as the field half, against a different
// key.
val HOOK_APPLY = Set("apply_filters", "apply_filters_ref_array", "do_action", "do_action_ref_array")
// The registry's vocabulary arrives as a FACT, never as a constant here. `apply_filters` is WordPress's
// name for this; Django signals, jQuery events and Symfony's dispatcher all have their own, and each is a
// row in a semantic fact table rather than an edit to this query.
val hookApply = split(dispatchApplyS).toSet
val hookTable: Map[String, List[String]] =
hookCallbacksS
@@ -399,9 +404,9 @@
)
def hookSourceNodes =
if (hookTable.isEmpty) Iterator.empty
if (hookTable.isEmpty || hookApply.isEmpty) Iterator.empty
else {
val applies = cpg.call.filter(c => HOOK_APPLY.contains(c.name)).filter(c => inScope(c.method)).l
val applies = cpg.call.filter(c => hookApply.contains(c.name)).filter(c => inScope(c.method)).l
if (applies.isEmpty) Iterator.empty
else {
val keys = applies.flatMap { call =>
@@ -569,6 +574,7 @@
field("sinks"),
field("sanitizers"),
field("hookCallbacks"),
field("dispatchApply"),
field("function"),
paramSrc,
field("file"),
@@ -586,7 +592,7 @@
println(ujson.write(ujson.Obj.from(answers.toSeq :+ ("__census__" -> census))))
} else {
println(
ujson.write(ujson.Arr(rowsFor(sources, sinks, sanitizers, hookCallbacks, function, parameterSources, file, callDepth, boundedSinks): _*))
ujson.write(ujson.Arr(rowsFor(sources, sinks, sanitizers, hookCallbacks, dispatchApply, function, parameterSources, file, callDepth, boundedSinks): _*))
)
}
println("---OUSAST-CPG-END---")