mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
fix(cpg): prove the frontend can read the source before building, and check the graph always
Two permanent guards, and a correction that motivates both.
THE CORRECTION. There is no php2cpg defect. Every PHP build on this machine
ran through a local `php` shim proxying stdio through `docker run`, and
php2cpg depends on the INTERLEAVING of its parser's stderr banners with its
stdout JSON to attribute each document to a file. The proxy scrambles it:
streaming shim drops 72-88 767KB / 969KB / 1.2MB different every run
buffered shim drops 0 5,703 bytes empty, and silent
NATIVE php drops 0 2,872,996 bytes identical x3
The 637-file tree with native php: 0 drops, a 4,329,471-byte CPG, 20.0s,
1.9GB peak -- which is exactly what task 5.10 said before I started
"correcting" it. No intermittent failures, no malformed graph, no dataflow
assertion, no scale limit.
GUARD 1: the interpreter must prove it can read the tree, before anything is
built. Every instrument failure this project has had takes one shape -- the
input was unreadable and the tool reported a plausible ZERO -- and it has cost
four wrong diagnoses in a day:
a php that could not see ~/.cache -> "cannot handle 637 files"
a php whose stdio was proxied -> "drops half its batches"
a container missing a bind mount -> "0 dropped", 5,703-byte graph
None announce themselves: file_exists() is false, the parser writes nothing,
the frontend exits 0, and "no errors" reads exactly like success. Checking the
output cannot separate that from an empty repository. Checking that the input
arrived can, and costs one exec.
GUARD 2: the graph census is taken on EVERY build, not only one the frontend
warned about. A graph holding nothing arrives with no warnings at all --
silence is the symptom that has no symptom -- and the shortfall is now
reported as "the frontend reported no failures but the graph holds N of M
source files".
`cpg_build_failed` carries that detail through to the report instead of being
a bare reason.
Also adds tests/test_cpg_queries_compile.py: the queries are 900 lines of
Scala that nothing compiled. A rowsFor signature change shipped with 850 tests
passing and broke every query at runtime, found by a measurement failing hours
later. Each query is now compiled AND run against a real graph wherever joern
exists, and must return a parseable payload.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
bffadd5741
commit
07df314e12
@@ -80,6 +80,11 @@ _FRONTENDS = {
|
||||
_PREFER_FRONTEND = frozenset({"php"})
|
||||
# The extensions that count as source for a language, used to ask whether a graph is missing anything.
|
||||
_LANGUAGE_EXTENSIONS: dict[str, tuple[str, ...]] = {"php": (".php",)}
|
||||
# Frontends that shell out to a separate interpreter, and the command that proves it can read a file.
|
||||
# `php2cpg` drives PHP-Parser through whatever `php` is on PATH, so the frontend is only as good as that
|
||||
# interpreter's view of the filesystem -- and a `php` that cannot see the tree does not fail, it produces an
|
||||
# empty graph with exit status 0 and no warnings.
|
||||
_INTERPRETERS: dict[str, str] = {"php": "php"}
|
||||
FRONTEND_BUILD_ATTEMPTS = 4
|
||||
|
||||
# Request fields that describe the REPOSITORY rather than the region, and are therefore identical in every
|
||||
@@ -184,6 +189,8 @@ class JoernBackend:
|
||||
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)
|
||||
# Why the last build refused, so the driver can name it instead of reporting a bare `cpg_build_failed`.
|
||||
last_failure: str = ""
|
||||
|
||||
def available(self) -> bool:
|
||||
from .capability import has_cpg
|
||||
@@ -202,6 +209,23 @@ class JoernBackend:
|
||||
scratch = Path(tempfile.mkdtemp(prefix="ousast-cpg-"))
|
||||
cpg_path = scratch / "cpg.bin"
|
||||
|
||||
# THE PRECONDITION. Before anything is built, make the toolchain open one file from the tree and say
|
||||
# how big it is. Every instrument failure this project has had takes the same shape -- the input was
|
||||
# unreadable and the tool reported a plausible ZERO -- and it has cost four wrong diagnoses:
|
||||
#
|
||||
# a php that could not see ~/.cache -> "php2cpg cannot handle 637 files" (it read none of them)
|
||||
# a php whose stdio was proxied -> "php2cpg drops half its batches" (it dropped none)
|
||||
# a container missing a bind mount -> "0 files dropped", 5,703-byte graph
|
||||
#
|
||||
# None of those announced themselves. `file_exists()` returns false, the parser writes nothing, the
|
||||
# frontend exits 0, and "no errors" reads exactly like success. Checking the OUTPUT cannot separate
|
||||
# those from a genuinely empty repository; checking that the INPUT arrived can, and costs one exec.
|
||||
unreadable = self._interpreter_cannot_read(root, language)
|
||||
if unreadable:
|
||||
logger.error("cpg build refused for %s: %s", root, unreadable)
|
||||
self.last_failure = unreadable
|
||||
return None
|
||||
|
||||
# A frontend whose failures are visible and retryable is worth more than one whose are not.
|
||||
if language.lower() in _PREFER_FRONTEND:
|
||||
shards, unparsed = self._build_sharded(root, scratch, language)
|
||||
@@ -235,6 +259,36 @@ class JoernBackend:
|
||||
unparsed=unparsed,
|
||||
)
|
||||
|
||||
def _interpreter_cannot_read(self, root: Path, language: str) -> str:
|
||||
"""``""`` when the language's interpreter can read the tree, else why not.
|
||||
|
||||
Deliberately narrow: it proves one file is openable and non-empty FROM THE INTERPRETER'S OWN VIEW,
|
||||
which is the thing that differs when `php` is a wrapper, a container without the right bind mount, or
|
||||
a tree of symlinks pointing somewhere the sandbox cannot follow. Nothing else here can see that
|
||||
difference, because every other signal is downstream of the parse that never happened.
|
||||
"""
|
||||
interpreter = _INTERPRETERS.get(language.lower())
|
||||
extensions = _LANGUAGE_EXTENSIONS.get(language.lower())
|
||||
if not interpreter or not extensions:
|
||||
return ""
|
||||
binary = shutil.which(interpreter)
|
||||
if binary is None:
|
||||
return "" # absent is a different failure, and the frontend reports it plainly
|
||||
sample = next((path for path in sorted(root.rglob("*")) if path.suffix.lower() in extensions and path.is_file()), None)
|
||||
if sample is None:
|
||||
return "" # nothing of this language to read; not the interpreter's fault
|
||||
probe = "$f = $argv[1]; if (!is_readable($f) || filesize($f) < 1) { exit(3); } exit(0);"
|
||||
completed = self._run([binary, "-r", probe, str(sample)], timeout=60)
|
||||
if completed is None:
|
||||
return f"the {interpreter} interpreter did not respond when asked to read {sample.name}"
|
||||
if completed.returncode != 0:
|
||||
return (
|
||||
f"the {interpreter} interpreter cannot read {sample}, so the frontend would parse nothing and "
|
||||
f"write an empty graph. Check that {interpreter} is a real interpreter with access to this "
|
||||
f"tree -- a container or wrapper without the right mount fails exactly this way."
|
||||
)
|
||||
return ""
|
||||
|
||||
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.
|
||||
|
||||
@@ -254,6 +308,12 @@ class JoernBackend:
|
||||
if dropped is None:
|
||||
return (), ()
|
||||
if not dropped:
|
||||
# A build that warned about nothing is exactly the build that needs checking, because a graph
|
||||
# holding nothing arrives with no warnings at all. Silence is the symptom that has no symptom.
|
||||
census = self._graph_census(main, root, language)
|
||||
if census is not None and census[0] < census[1]:
|
||||
self.last_failure = f"the frontend reported no failures but the graph holds {census[0]} of {census[1]} source files"
|
||||
logger.error("cpg build for %s: %s", root, self.last_failure)
|
||||
return (main,), ()
|
||||
|
||||
# A warning is a symptom, not a verdict. `php2cpg` logs `Failed to process` for files that are
|
||||
@@ -282,26 +342,31 @@ class JoernBackend:
|
||||
return tuple(shards), tuple(still)
|
||||
return tuple(shards), dropped
|
||||
|
||||
def _graph_is_complete(self, cpg_path: Path, root: Path, language: str) -> bool:
|
||||
"""Does this graph hold at least as many files as the tree has sources?
|
||||
def _graph_census(self, cpg_path: Path, root: Path, language: str) -> tuple[int, int] | None:
|
||||
"""``(files_in_graph, files_expected)``, or ``None`` when the question cannot be asked.
|
||||
|
||||
Conservative in the only direction that matters: when the census cannot be taken, or the language
|
||||
has no declared extensions, the answer is "no" and the caller does the careful thing.
|
||||
Always asked, not only when the frontend warned. The dangerous build is the SILENT one: a graph that
|
||||
holds nothing, reported with no errors at all, which is what an unreadable tree produces and what a
|
||||
genuinely clean scan of an empty repository looks like. A warning is a symptom; this is the evidence.
|
||||
"""
|
||||
extensions = _LANGUAGE_EXTENSIONS.get(language.lower())
|
||||
if not extensions:
|
||||
return False
|
||||
return None
|
||||
expected = sum(1 for path in root.rglob("*") if path.suffix.lower() in extensions and path.is_file())
|
||||
if not expected:
|
||||
return False
|
||||
return None
|
||||
payload = self.query(cpg_path, "census", {})
|
||||
if not isinstance(payload, Mapping):
|
||||
return False
|
||||
return None
|
||||
try:
|
||||
files = int(str(payload.get("files", "0")))
|
||||
return int(str(payload.get("files", "0"))), expected
|
||||
except ValueError:
|
||||
return False
|
||||
return files >= expected
|
||||
return None
|
||||
|
||||
def _graph_is_complete(self, cpg_path: Path, root: Path, language: str) -> bool:
|
||||
"""Conservative: when the census cannot be taken, the answer is "no" and the caller is careful."""
|
||||
census = self._graph_census(cpg_path, root, language)
|
||||
return census is not None and census[0] >= census[1]
|
||||
|
||||
def _island_root(self, root: Path, scratch: Path, files: Sequence[str]) -> Path | None:
|
||||
"""A tree holding only ``files``, at their original relative paths.
|
||||
|
||||
@@ -123,7 +123,7 @@ def scan_repository(
|
||||
regions_unjudged=len(regions),
|
||||
seconds=round(time.monotonic() - started, 2),
|
||||
build_seconds=build_seconds,
|
||||
degradations=({"stage": "model", "reason": "cpg_build_failed"},),
|
||||
degradations=({"stage": "model", "reason": "cpg_build_failed", "detail": getattr(backend, "last_failure", "")},),
|
||||
)
|
||||
|
||||
# A CPG the frontend built with files missing is not the repository the caller asked about, and no
|
||||
|
||||
@@ -427,7 +427,7 @@ def _append_coverage(lines: list[str], coverage: Sequence[Mapping[str, object]],
|
||||
# silence in the table above is narrower than it looks.
|
||||
_COVERAGE_DEGRADATIONS: dict[str, str] = {
|
||||
"cpg_empty": "The code graph came back with no methods in it, so **nothing above was analysed at all**.",
|
||||
"cpg_build_failed": "The code graph could not be built, so nothing above was analysed.",
|
||||
"cpg_build_failed": "The code graph could not be built, so nothing above was analysed. {detail}",
|
||||
"cpg_sharded": (
|
||||
"The graph had to be built in {shards} parts, because the frontend could not parse the whole tree at "
|
||||
"once. Each part was queried, but **a flow whose source is in one part and whose sink is in another "
|
||||
|
||||
Reference in New Issue
Block a user