mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 12:09:27 +00:00
Scanning a two-file WordPress slice returned 64 regions examined, zero
findings and zero degradations. The graph it examined had no methods in it.
php2cpg 4.0.623 feeds several files to one `php-parse` process and reads its
stdout and stderr merged. php-parse writes a `====> File <next>:` banner to
stderr the moment it starts the next file, while the previous file's JSON --
megabytes of it -- is still draining from a block-buffered stdout. The banner
lands inside the JSON, ujson reports
expected json value or } got "=" at index 5011645
and the frontend drops that file. Reproducible on real plugin code, and the
size that matters is one file's AST dump, not the repository's:
class.memberorder.php alone (1834 lines) -> 142KB CPG, no failures
the same file + a two-line file -> 5.7KB CPG, BOTH dropped
One oversized file empties the whole graph. `joern-parse` then does not
propagate any of the frontend's warnings: 13 lines of output, exit status 0,
"Successfully wrote graph". This is what made a 637-file plugin unscannable
and reported it as clean -- it was never a scale limit.
Two fixes, because the failure arrives on two different paths:
* `_unparsed_files` reads the per-file warnings the frontend does emit, and
the scan reports them as `files_unparsed`. This covers the direct-frontend
path where the warnings are visible.
* The batched query now carries a `__census__` of the graph -- method and
file counts -- under a key no request id can collide with, and a scan over
a graph with no methods degrades as `cpg_empty`. This covers joern-parse,
which shows nothing. It rides the batch rather than costing its own
invocation, because avoiding a JVM start is the entire reason batching
exists.
Neither invents a verdict. There is no rung for "the graph was empty" because
the ladder labels verdicts and none was produced, which is exactly why it has
to be a degradation.
pmpro two-file slice before: 0 findings, degradations=[]
after: 0 findings, degradations=[cpg_empty]
Gates byte-identical; 840 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
343 lines
17 KiB
Python
343 lines
17 KiB
Python
"""The Joern subprocess seam — the single place this codebase touches a CPG engine (Req 4.1, 4.4, 4.5).
|
|
|
|
Joern is JVM/Scala. Everything crosses one subprocess boundary: ``joern-parse`` builds a ``cpg.bin`` from a
|
|
source tree, then ``joern --script`` runs a CPGQL script that prints a JSON payload. Nothing is imported
|
|
in-process, so the core install keeps ``dependencies = []`` and an absent engine costs a recorded reason
|
|
rather than a traceback.
|
|
|
|
Two rules hold everywhere below:
|
|
|
|
* **Fail closed.** A build or query that errors, times out, or prints something unparseable yields *no*
|
|
verdict. The model layer then reports ``suspicion``. A wrong verdict is worse than no verdict — that is the
|
|
whole reason this arbiter exists.
|
|
* **Fence the payload.** Joern prints a banner, compilation notices and a REPL prompt on stdout. The script
|
|
brackets its JSON between markers so parsing can never swallow the noise around it.
|
|
|
|
We do not build a control-flow or dataflow engine here (Req 4.4): the CFG, PDG and call graph our flat IR
|
|
lacks are exactly what is being adopted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from collections.abc import Callable, Mapping
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Protocol
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
QUERIES_DIR = Path(__file__).resolve().parent / "queries"
|
|
|
|
# The payload fence. Joern's stdout carries a banner and a prompt; the script prints these around its JSON.
|
|
BEGIN = "---OUSAST-CPG-BEGIN---"
|
|
END = "---OUSAST-CPG-END---"
|
|
|
|
BUILD_TIMEOUT_SECONDS = 900 # a large tree takes minutes; the go/no-go records what it actually cost
|
|
QUERY_TIMEOUT_SECONDS = 300
|
|
# A JVM with no -Xmx takes a quarter of physical RAM for its heap. On a contributor's laptop that is the
|
|
# difference between a scan running in the background and a scan the machine notices, and it made the
|
|
# first repository measurements unreproducible: runs were killed under memory pressure at different
|
|
# points, so the numbers described the host, not the engine. Bound it, and let the operator raise it.
|
|
CPG_HEAP_MB = 2048
|
|
HEAP_ENV = "OPENULTRASAST_CPG_HEAP_MB"
|
|
|
|
# `joern-parse` detects the language and then shells out to a frontend, and on a large tree it can fail where
|
|
# the frontend alone succeeds -- measured on a 637-file WordPress plugin, where joern-parse threw after
|
|
# applying overlays while `php2cpg` on the same tree produced a 4.3MB CPG with no errors. Joern's own message
|
|
# recommends the direct route for large codebases, so this is the documented fallback rather than a
|
|
# workaround. The names are Joern's frontend executables.
|
|
_FRONTENDS = {
|
|
"php": "php2cpg",
|
|
"python": "pysrc2cpg",
|
|
"javascript": "jssrc2cpg",
|
|
"typescript": "jssrc2cpg",
|
|
"java": "javasrc2cpg",
|
|
"c": "c2cpg",
|
|
"c_cpp": "c2cpg",
|
|
}
|
|
|
|
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
|
|
|
|
def extract_payload(stdout: str) -> object | None:
|
|
"""The JSON between the fence markers, or ``None`` when it is absent or malformed."""
|
|
start = stdout.find(BEGIN)
|
|
end = stdout.find(END, start + 1) if start >= 0 else -1
|
|
if start < 0 or end < 0:
|
|
return None
|
|
body = stdout[start + len(BEGIN) : end].strip()
|
|
try:
|
|
payload: object = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
logger.debug("cpg query payload was not valid JSON")
|
|
return None
|
|
return payload
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CpgResult:
|
|
"""A built CPG and the ways to query it. Opaque: only ``model/`` reads through these.
|
|
|
|
``run_batch`` is optional so a backend that cannot answer many questions in one invocation still fits
|
|
this shape -- the driver falls back to ``run``, once per request. But a backend that CAN batch must
|
|
attach it HERE, because this is the driver's only way to discover the capability. Leaving it off does
|
|
not fail: it silently costs a JVM per region, which is how batching sat unreachable behind a passing
|
|
test suite until a repository scan made the cost visible.
|
|
"""
|
|
|
|
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 = None
|
|
# Files the frontend read and could not turn into a graph. A CPG is not all-or-nothing: `php2cpg` logs a
|
|
# warning per file it drops and still exits 0 with a graph that is missing them, so a scan over the
|
|
# remainder is a scan of LESS CODE THAN IT WAS ASKED ABOUT, and silence about those files is the same
|
|
# quiet failure as an empty query result. The names travel with the CPG so the driver can say so.
|
|
unparsed: tuple[str, ...] = ()
|
|
|
|
|
|
class CpgBackend(Protocol):
|
|
def available(self) -> bool: ...
|
|
|
|
def build(self, root: Path) -> CpgResult | None: ...
|
|
|
|
|
|
@dataclass
|
|
class NullBackend:
|
|
"""No engine. Every verdict degrades to ``suspicion`` and the caller records ``cpg_unavailable``."""
|
|
|
|
reason: str = "cpg_unavailable"
|
|
|
|
def available(self) -> bool:
|
|
return False
|
|
|
|
def build(self, root: Path) -> CpgResult | None:
|
|
del root
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class JoernBackend:
|
|
"""``joern-parse`` then ``joern --script``, both out of process and both timeout-bounded."""
|
|
|
|
runner: Runner | None = None
|
|
build_timeout: int = BUILD_TIMEOUT_SECONDS
|
|
query_timeout: int = QUERY_TIMEOUT_SECONDS
|
|
heap_mb: int = 0 # 0 means read the environment, then fall back to CPG_HEAP_MB
|
|
queries_dir: Path = field(default_factory=lambda: QUERIES_DIR)
|
|
|
|
def available(self) -> bool:
|
|
from .capability import has_cpg
|
|
|
|
return has_cpg()
|
|
|
|
def build(self, root: Path, *, language: str = "") -> CpgResult | None:
|
|
"""Build a CPG for ``root``. ``None`` on any failure — the caller degrades, never guesses.
|
|
|
|
``language`` lets a failed ``joern-parse`` retry through the frontend directly, which is what Joern
|
|
itself advises for a large codebase and what a 637-file WordPress plugin needed.
|
|
"""
|
|
parse = shutil.which("joern-parse")
|
|
if parse is None and os.environ.get("OPENULTRASAST_JOERN_PROBE", "").strip().lower() not in {"1", "on", "true", "yes"}:
|
|
return None
|
|
scratch = Path(tempfile.mkdtemp(prefix="ousast-cpg-"))
|
|
cpg_path = scratch / "cpg.bin"
|
|
command = [parse or "joern-parse", self._heap_flag(), str(root), "--output", str(cpg_path)]
|
|
completed = self._run(command, timeout=self.build_timeout, cwd=scratch)
|
|
unparsed = _unparsed_files(completed)
|
|
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("cpg build failed for %s: %s", root, detail)
|
|
retried = self._build_with_frontend(root, cpg_path, scratch, language)
|
|
if retried is None:
|
|
return None
|
|
unparsed = retried
|
|
if unparsed:
|
|
logger.warning("the frontend could not parse %d file(s) under %s: %s", len(unparsed), root, ", ".join(unparsed[:5]))
|
|
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),
|
|
unparsed=unparsed,
|
|
)
|
|
|
|
def _build_with_frontend(self, root: Path, cpg_path: Path, scratch: Path, language: 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
|
|
with or the retry failed too. An empty tuple and ``None`` are different answers here for the same
|
|
reason they are everywhere else in this module: one means "built, and complete", the other means
|
|
"could not build".
|
|
"""
|
|
frontend = _FRONTENDS.get(language.lower())
|
|
if not frontend:
|
|
return None
|
|
binary = shutil.which(frontend)
|
|
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)
|
|
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)
|
|
return None
|
|
logger.info("built the cpg for %s with %s after joern-parse failed", root, frontend)
|
|
return _unparsed_files(completed)
|
|
|
|
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
|
|
starts a JVM of roughly thirty seconds, and issuing one per region per family put a ten-line Python
|
|
file at four minutes and a thousand regions at about fifty hours. The CPG is already built and loaded
|
|
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, 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 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
|
|
# WordPress plugin is a 1.49MB payload. execve returns E2BIG, `_run` catches the OSError, this returns
|
|
# {} -- and {} reads as "no rows found". The scan reported 500 regions examined and nothing found, in
|
|
# 0.05 seconds of taint query, which is a quiet failure of exactly the kind the rung ladder cannot
|
|
# catch because no verdict was ever produced to label.
|
|
requests_file = cpg_path.parent / f"{query}-requests.json"
|
|
try:
|
|
requests_file.write_text(payload)
|
|
except OSError as exc:
|
|
logger.warning("could not stage cpg batch %s: %s", query, exc)
|
|
return None
|
|
command = [
|
|
shutil.which("joern") or "joern",
|
|
self._heap_flag(),
|
|
"--script",
|
|
str(script),
|
|
"--param",
|
|
f"cpgFile={cpg_path}",
|
|
"--param",
|
|
f"requestsFile={requests_file}",
|
|
]
|
|
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
|
|
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 None
|
|
parsed = extract_payload(completed.stdout or "")
|
|
if not isinstance(parsed, Mapping):
|
|
# 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)}
|
|
|
|
def query(self, cpg_path: Path, query: str, params: Mapping[str, object]) -> object | None:
|
|
"""Run a shipped CPGQL script against a built CPG and return its fenced JSON payload."""
|
|
script = self.queries_dir / f"{query}.sc"
|
|
if not script.is_file():
|
|
logger.warning("no such cpg query: %s", script)
|
|
return None
|
|
command = [shutil.which("joern") or "joern", self._heap_flag(), "--script", str(script), "--param", f"cpgFile={cpg_path}"]
|
|
for key, value in sorted(params.items()):
|
|
command += ["--param", f"{key}={_render(value)}"]
|
|
# Joern writes a `workspace/` beside the working directory; run it inside the CPG's own scratch dir so
|
|
# it can never land in the repository being analysed.
|
|
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
|
|
if completed is None or completed.returncode != 0:
|
|
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
|
|
logger.warning("cpg query %s failed: %s", query, detail)
|
|
return None
|
|
return extract_payload(completed.stdout or "")
|
|
|
|
def _heap_flag(self) -> str:
|
|
"""``-J-Xmx``: both launchers forward ``-J`` arguments to the JVM."""
|
|
if self.heap_mb > 0:
|
|
return f"-J-Xmx{self.heap_mb}m"
|
|
configured = os.environ.get(HEAP_ENV, "").strip()
|
|
return f"-J-Xmx{configured if configured.isdigit() else CPG_HEAP_MB}m"
|
|
|
|
def _run(self, command: list[str], *, timeout: int, cwd: Path | None = None) -> subprocess.CompletedProcess[str] | None:
|
|
run = self.runner if self.runner is not None else subprocess.run
|
|
try:
|
|
return run(command, capture_output=True, text=True, timeout=timeout, check=False, cwd=str(cwd) if cwd else None)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
|
|
|
|
# Every Joern frontend logs a dropped file the same way, at WARN, and then carries on:
|
|
#
|
|
# WARN AstCreationPass Failed to process '/abs/path/to/file.php'
|
|
#
|
|
# The quotes are part of the line, which is what makes this safely greppable rather than a guess at column
|
|
# positions. Several passes report the same file, so the caller de-duplicates.
|
|
_UNPARSED = re.compile(r"Failed to process '([^']+)'")
|
|
|
|
|
|
def _unparsed_files(completed: subprocess.CompletedProcess[str] | None) -> tuple[str, ...]:
|
|
"""The files the frontend read and dropped, in first-seen order.
|
|
|
|
This exists because a Joern frontend fails a file WITHOUT failing the build. `php2cpg` 4.0.623 feeds
|
|
several files to one `php-parse` process and reads its stdout and stderr merged; php-parse writes a
|
|
`====> File <next>:` banner to stderr the moment it starts the next file, while the previous file's JSON
|
|
-- megabytes of it -- is still draining from a block-buffered stdout. The banner lands inside the JSON,
|
|
ujson reports `expected json value got "="`, and the frontend drops that file. Reproducibly, on real
|
|
plugin code:
|
|
|
|
one 1834-line file alone -> a 142KB CPG, no failures
|
|
the same file plus a two-line file -> a 5.7KB CPG, BOTH files dropped, exit status 0
|
|
|
|
So the size that matters is one file's AST dump, not the repository's, and one oversized file can empty
|
|
the whole graph. Before this, that arrived as a scan reporting no findings and no degradations -- a clean
|
|
bill of health over a graph containing nothing at all.
|
|
"""
|
|
if completed is None:
|
|
return ()
|
|
seen: dict[str, None] = {}
|
|
for stream in (completed.stdout or "", completed.stderr or ""):
|
|
for match in _UNPARSED.finditer(stream):
|
|
seen.setdefault(match.group(1), None)
|
|
return tuple(seen)
|
|
|
|
|
|
def _render(value: object) -> str:
|
|
"""CPGQL parameters are strings; a sequence becomes a comma-separated list the script splits."""
|
|
if isinstance(value, str):
|
|
return value
|
|
if isinstance(value, (list, tuple, set, frozenset)):
|
|
return ",".join(sorted(str(item) for item in value))
|
|
return str(value)
|
|
|
|
|
|
def resolve_cpg_backend(*, runner: Runner | None = None) -> CpgBackend:
|
|
"""The backend for this machine: Joern when it is installed, else the null one."""
|
|
from .capability import has_cpg
|
|
|
|
return JoernBackend(runner=runner) if has_cpg() else NullBackend()
|
|
|
|
|
|
__all__ = [
|
|
"BEGIN",
|
|
"END",
|
|
"CpgBackend",
|
|
"CpgResult",
|
|
"JoernBackend",
|
|
"NullBackend",
|
|
"extract_payload",
|
|
"resolve_cpg_backend",
|
|
]
|