fix(cpg): a query that could not be asked is not an empty answer

contributor-scan 5.7, and it was the wrong question.

**The 117k-line pmpro CPG does not load.** Importing it throws in a
ForkJoinParallelCpgPassWithAccumulator during overlay application, so every
query against it returns nothing. It is not a call-graph question at all.

Isolated by running the ARBITER against two prebuilt CPGs rather than
writing a sixth ad-hoc Scala probe, four of which had already failed on my
own mistakes:

  small wpdb CPG   get_order_by_code       -> 1 model_entailed
  pmpro CPG        getMemberOrderByCode    -> 0

Both have their source and sink in ONE function, both were asked with
parameter sources and depth 3, same arbiter and same spec. The CPG is the
only difference.

That clears the facts, which is worth stating: `$wpdb` sinks work, the
parameter-source rule works, and a PHP parameter reaching `$wpdb->get_var`
through a concat is entailed. Whether php2cpg resolves `new X()` and
`$this->method()` is still unmeasured -- it cannot be measured on a CPG
that will not load -- and is recorded as 5.10 along with finding the size
boundary.

**And the defect underneath it, which is the fourth quiet failure found on
this one checkout.** `query_batch` returned `{}` for a failure and `{}` for
a genuine empty answer, so a caller could not tell "could not ask" from
"nothing found". Every query on this scan failed at load and it reported
500 regions examined, no findings, and no degradation: a vulnerable plugin,
clean.

A failed batch now returns None, unparseable output counts as a failure
rather than an empty answer, and the driver records a `query_failed`
degradation naming the kind and the request count.

This is the failure mode the rung ladder structurally cannot catch. Every
other honesty mechanism here labels a VERDICT -- suspicion,
regions_unjudged, regions_unasked, stated family limits -- and when no
verdict is produced there is nothing to label.

Gates byte-identical.

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 17:15:51 +02:00
co-authored by Claude Opus 5
parent db273dde53
commit 21fdb554aa
8 changed files with 133 additions and 20 deletions
+14 -8
View File
@@ -93,7 +93,7 @@ class CpgResult:
cpg_path: Path
run: Callable[[str, Mapping[str, object]], object | None]
run_batch: Callable[[str, Mapping[str, Mapping[str, object]]], dict[str, list[object]]] | None = None
run_batch: Callable[[str, Mapping[str, Mapping[str, object]]], dict[str, list[object]] | None] | None = None
class CpgBackend(Protocol):
@@ -172,7 +172,7 @@ class JoernBackend:
logger.info("built the cpg for %s with %s after joern-parse failed", root, frontend)
return True
def query_batch(self, cpg_path: Path, query: str, requests: Mapping[str, Mapping[str, object]]) -> dict[str, list[object]]:
def query_batch(self, cpg_path: Path, query: str, requests: Mapping[str, Mapping[str, object]]) -> dict[str, list[object]] | None:
"""Run ONE script invocation carrying many requests, keyed back to their ids.
JVM startup, not CPG construction, is what dominates a repository scan: every ``joern --script`` call
@@ -181,13 +181,15 @@ class JoernBackend:
by then; the work itself is milliseconds. So the batch carries the whole scan's questions in one
parameter and the script loops over them.
Fails closed like every other path here: an engine that could not answer returns ``{}``, never an
empty result per request, because "no rows" and "could not ask" must stay distinguishable.
Fails closed like every other path here, and says which kind of nothing it is: an engine that could
not answer returns ``None`` while a genuine empty answer is ``{}``. That distinction is the whole
point -- "no rows" and "could not ask" look identical to a caller that only sees a dict, and a
117k-line PHP CPG that threw while loading turned a failed scan into a clean bill of health.
"""
script = self.queries_dir / f"{query}.sc"
if not script.is_file():
logger.warning("no such cpg query: %s", script)
return {}
return None
payload = json.dumps({rid: {k: _render(v) for k, v in req.items()} for rid, req in requests.items()})
# Through a FILE, never the command line. Linux caps a single argument at MAX_ARG_STRLEN (128KB)
# whatever ARG_MAX says, and a repository blows through that: 2500 taint requests over a 637-file
@@ -200,7 +202,7 @@ class JoernBackend:
requests_file.write_text(payload)
except OSError as exc:
logger.warning("could not stage cpg batch %s: %s", query, exc)
return {}
return None
command = [
shutil.which("joern") or "joern",
self._heap_flag(),
@@ -215,10 +217,14 @@ class JoernBackend:
if completed is None or completed.returncode != 0:
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
logger.warning("cpg batch %s failed: %s", query, detail)
return {}
return None
parsed = extract_payload(completed.stdout or "")
if not isinstance(parsed, Mapping):
return {}
# Unparseable output is a failure, not an empty answer. A 117k-line PHP CPG that throws while
# loading printed a stack trace and no payload, and returning {} made the whole scan read as a
# clean repository -- 500 regions examined, nothing found, no degradation recorded.
logger.warning("cpg batch %s returned no parseable payload", query)
return None
# A request the engine did not answer is absent, not empty: the caller must be able to tell them apart.
return {str(rid): list(rows) for rid, rows in parsed.items() if isinstance(rows, list)}
+8 -1
View File
@@ -155,7 +155,14 @@ def scan_repository(
kind_started = time.monotonic()
batch = getattr(cpg, "run_batch", None)
if callable(batch):
rows_by_id.update(batch(kind, requests) or {})
answered_batch = batch(kind, requests)
if answered_batch is None:
# The engine could not answer. That is NOT an empty result, and recording it is what keeps a
# failed scan from reading as a clean repository: on a 117k-line PHP checkout every query
# failed at CPG load and the scan reported 500 regions examined with nothing found.
degradations.append({"stage": "model", "reason": "query_failed", "kind": kind, "requests": len(requests)})
else:
rows_by_id.update(answered_batch)
else: # a backend without batching still works, one call at a time
for rid, params in requests.items():
answered = cpg.run(kind, params)