fix(cpg): one method with no sources must not cost the whole batch its answers

`reachableByFlows` on an empty source list is not a no-op. Joern answers it by
logging

    [INFO ] Attempting to determine flows from empty list of sources.

to STDOUT -- while the script is running, so the line lands between the fence
markers, ahead of the payload. `json.loads` then fails on the whole document
and the driver reports `query_failed` for every request in the batch. One
method with nothing to trace from silently cost the answers for all 205 of
the others.

Fixed at both ends, because either alone leaves the seam brittle:

  * taint.sc skips the flow query when there is no source to trace from, which
    is also the correct thing to do with the question.
  * `extract_payload` starts a decode at each line that opens a document and
    keeps the first that parses. Trimming to the outermost brackets is the
    obvious approach and the wrong one -- `[INFO ]` brings its own bracket.

Found while testing whether `$this` belongs in parameter sources. It does not
belong there on the argument -- the receiver is the object, not request data --
and excluding it cuts Paid Memberships Pro's order class from 16 entailed
findings to 6, all sourced from a named parameter, and makes the 2.9.7/2.9.8
pair separate cleanly.

It also loses CVE-2023-6559: MW WP Form's `_delete_files()` takes no
parameters at all, and the attacker-controlled path arrives as
`$this->attachments`, assigned by another method. So the trade is a VERIFIED
CVE against findings merely suspected of being false, and it is refused. The
receiver stays, with the measurement written next to it. The real defect is
field-sensitivity -- a field should be tainted where it is assigned -- and
deleting the receiver removes the symptom by removing the evidence.

841 tests pass; 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 18:30:41 +02:00
co-authored by Claude Opus 5
parent e0442d146c
commit 45cf43e625
3 changed files with 66 additions and 5 deletions
+29 -4
View File
@@ -73,14 +73,39 @@ def extract_payload(stdout: str) -> object | None:
if start < 0 or end < 0:
return None
body = stdout[start + len(BEGIN) : end].strip()
try:
payload: object = json.loads(body)
except json.JSONDecodeError:
# Joern logs to STDOUT, and it does so while the script is running -- so a log line can land INSIDE the
# fence, ahead of the payload. `[INFO ] Attempting to determine flows from empty list of sources.` did
# exactly that and cost a 205-request batch every one of its answers, reported as `query_failed`. The
# payload is always one JSON object or array, so trim to its own brackets rather than trusting the fence
# to contain nothing else.
payload = _decoded_json(body)
if payload is None:
logger.debug("cpg query payload was not valid JSON")
return None
return payload
def _decoded_json(body: str) -> object | None:
"""The first complete JSON document in ``body``, ignoring whatever Joern logged around it.
Trimming to the outermost brackets is the obvious approach and the wrong one: a log line is `[INFO ] ...`,
so its own bracket comes first. This starts a decode at each line that opens a document and keeps the
first that parses, which is exactly one line of work when nothing was logged.
"""
decoder = json.JSONDecoder()
offset = 0
for line in body.splitlines(keepends=True):
if line.lstrip().startswith(("{", "[")):
try:
payload, _ = decoder.raw_decode(body, offset + len(line) - len(line.lstrip()))
except ValueError:
pass
else:
decoded: object = payload
return decoded
offset += len(line)
return None
@dataclass(frozen=True)
class CpgResult:
"""A built CPG and the ways to query it. Opaque: only ``model/`` reads through these.