mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 12:09:27 +00:00
fix(scan): itemise change context only in the files a change touched
The PHP transfer replay (pre-push 13.3) spent its whole 7,200 s case in the head side's evidence pass. A probe on one PMPro graph named the cause: not compute -- context adds ~20% engine time -- but OUTPUT. Each evidence request listed every method its scope reached, up to 1,845 rows; 200 requests into the bundled Braintree SDK returned 52,831 rows, 9.4 MB, against 211 rows without context, over 22,125 requests in a 3 GB container. `affected_context` only ever attaches a location in a changed file, and every other row was checked (real file under the root, usable extent) and discarded. The scan now sends the changed files; the engine itemises those and folds the rest into one `context_elsewhere` row carrying exactly what that check reads. With no safe file list the engine itemises everything, as before. Equivalence: unit controls over valid, missing-file, no-extent and outside-root cases, with and without a changed-file row; a real-Joern control; and on PMPro itself, 2,440 relationships and zero gaps decided identically from both forms, 52,831 rows down to 3,241 with the most referenced file as the change. Probe: benchmarks/push/context_cost_probe.py. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cda679cb3b
commit
3c195c9369
@@ -626,3 +626,56 @@ def test_the_taint_query_streams_one_line_per_request(tmp_path: Path) -> None:
|
||||
answers = [json.loads(line) for line in lines[1:]]
|
||||
assert all(isinstance(a.get("ms"), (int, float)) and a["ms"] >= 0 for a in answers), answers
|
||||
assert [t["id"] for t in assembled["__timing__"]] == ["0", "1"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
|
||||
def test_context_locations_outside_the_changed_files_are_summarised(tmp_path: Path) -> None:
|
||||
"""Only the changed files are itemised; every other location collapses into one checkable summary.
|
||||
|
||||
Itemised, one PHP request returned 1,845 locations and a push over a WordPress plugin never finished.
|
||||
"""
|
||||
frontend = __import__("shutil").which("jssrc2cpg")
|
||||
if frontend is None:
|
||||
pytest.skip("jssrc2cpg is not installed")
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "a.js").write_text(
|
||||
"const h = require('./b');\nfunction handler(req, res) { return h.helper(req.body.x); }\nmodule.exports = handler;\n"
|
||||
)
|
||||
(tmp_path / "src" / "b.js").write_text("function helper(x) { return eval(x); }\nmodule.exports = { helper };\n")
|
||||
cpg = tmp_path / "cpg.bin"
|
||||
subprocess.run([frontend, str(tmp_path / "src"), "-o", str(cpg)], capture_output=True, text=True, timeout=600, check=False)
|
||||
if not cpg.is_file():
|
||||
pytest.skip("could not build a sample cpg")
|
||||
# Repository-wide scope, so the context reaches methods in both files. A `require`d helper is not a
|
||||
# resolved call edge in the JavaScript graph, so a handler-scoped request would never leave `a.js`.
|
||||
request = {"sources": "req.body", "sinks": "eval", "evidenceOnly": "true", "contextEvidence": "true"}
|
||||
requests = tmp_path / "requests.json"
|
||||
requests.write_text(json.dumps({"whole": request, "filtered": {**request, "contextFilter": "true", "contextPaths": "b.js"}}))
|
||||
done = subprocess.run(
|
||||
[
|
||||
_joern() or "joern",
|
||||
"--script",
|
||||
str((QUERIES / "taint.sc").resolve()),
|
||||
"--param",
|
||||
f"cpgFile={cpg}",
|
||||
"--param",
|
||||
f"requestsFile={requests}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
check=False,
|
||||
)
|
||||
assert done.returncode == 0, (done.stderr or done.stdout or "")[-500:]
|
||||
from openultrasast.cpg.backend import extract_payload
|
||||
|
||||
payload = extract_payload(done.stdout)
|
||||
assert isinstance(payload, dict)
|
||||
whole = [r for r in payload["whole"] if r.get("kind") == "context_method"]
|
||||
kept = [r for r in payload["filtered"] if r.get("kind") == "context_method"]
|
||||
summary = [r for r in payload["filtered"] if r.get("kind") == "context_elsewhere"]
|
||||
assert {r["path"] for r in whole} >= {"a.js", "b.js"}, whole # the unfiltered form still itemises everything
|
||||
assert kept and all(r["path"] == "b.js" for r in kept), kept
|
||||
assert kept == [r for r in whole if r["path"] == "b.js"], "the changed file's locations must be unchanged"
|
||||
assert len(summary) == 1 and summary[0]["paths"] == sorted({r["path"] for r in whole if r["path"] != "b.js"})
|
||||
assert summary[0]["locations"] == len(whole) - len(kept)
|
||||
|
||||
@@ -386,3 +386,68 @@ def test_a_gap_in_another_partition_does_not_demote_this_one(tmp_path):
|
||||
assert php, result.question_outcomes
|
||||
if all(str(d.get("census_language") or "") != "php" for d in result.degradations):
|
||||
assert all(o.status == "completed" for o in php), [(o.status, o.reason) for o in php]
|
||||
|
||||
|
||||
def _summary(rows):
|
||||
"""What the engine now emits in place of itemised rows for files the change did not touch."""
|
||||
unusable = sum(1 for r in rows if int(r["startLine"]) < 1 or int(r["endLine"]) < int(r["startLine"]))
|
||||
return {"kind": "context_elsewhere", "locations": len(rows), "unusableExtents": unusable, "paths": sorted({r["path"] for r in rows})}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"elsewhere",
|
||||
[
|
||||
pytest.param([row("other.php"), row("other.php", "field", "f"), row("lib/deep.php", "hook", "h")], id="valid"),
|
||||
pytest.param([row("other.php"), row("missing.php")], id="missing-file"),
|
||||
pytest.param([row("other.php"), {**row("other.php", function="g"), "startLine": -1}], id="no-extent"),
|
||||
pytest.param([row("../outside.php")], id="outside-root"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("with_changed_row", [True, False])
|
||||
def test_summarised_locations_decide_exactly_what_itemised_ones_did(tmp_path, elsewhere, with_changed_row):
|
||||
"""Summarising unchanged-file locations in the engine must change nothing `affected_context` decides.
|
||||
|
||||
Itemised, one PHP request returned 1,845 locations and a push over a WordPress plugin never finished;
|
||||
every location outside the changed files was only ever checked and then discarded, so the summary carries
|
||||
exactly what that check reads.
|
||||
"""
|
||||
from openultrasast.model.contracts import QuestionIdentity
|
||||
from openultrasast.model.regions import affected_context
|
||||
|
||||
(tmp_path / "source.php").write_text("readable\nchanged\n")
|
||||
(tmp_path / "other.php").write_text("unchanged\n")
|
||||
(tmp_path / "lib").mkdir()
|
||||
(tmp_path / "lib" / "deep.php").write_text("unchanged\n")
|
||||
(tmp_path.parent / "outside.php").write_text("outside the root\n")
|
||||
question = QuestionIdentity("unit", "php", "sink.php", "run", "injection")
|
||||
changed = [row("source.php", "callee")] if with_changed_row else []
|
||||
head = [{"kind": "context_summary"}]
|
||||
itemised = head + changed + elsewhere
|
||||
summarised = head + changed + [_summary(elsewhere)]
|
||||
old = affected_context(context(), [question], {question: itemised}, tmp_path)
|
||||
new = affected_context(context(), [question], {question: summarised}, tmp_path)
|
||||
assert new == old
|
||||
|
||||
|
||||
def test_the_evidence_pass_names_the_changed_files_to_the_engine(tmp_path):
|
||||
"""The engine can only summarise what the driver says is unchanged; with no safe list it itemises all."""
|
||||
from openultrasast.model import scan
|
||||
|
||||
asked: list[dict[str, object]] = []
|
||||
|
||||
class Graph:
|
||||
cpg_path = tmp_path / "cpg.bin"
|
||||
|
||||
def run_batch(self, kind, requests):
|
||||
asked.extend(requests.values())
|
||||
return {rid: [] for rid in requests}
|
||||
|
||||
work = scan._collect([region("sink.php", "php")])
|
||||
context_rows: dict = {}
|
||||
scan._evidence_pass(Graph(), work, "", context_rows=context_rows, context_files=("a.php", "b,c.php"))
|
||||
assert asked and all(r["contextFilter"] == "true" and r["contextPaths"] == "a.php\nb,c.php" for r in asked)
|
||||
asked.clear()
|
||||
scan._evidence_pass(Graph(), work, "", context_rows=context_rows, context_files=None)
|
||||
assert asked and all("contextFilter" not in r for r in asked)
|
||||
assert scan._changed_files(context("x\ny.php")) is None
|
||||
assert scan._changed_files(context("source.php")) == ("source.php",)
|
||||
|
||||
Reference in New Issue
Block a user