mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +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
@@ -0,0 +1,133 @@
|
||||
"""Time the evidence pass with and without change context, on one graph, from the production request builder.
|
||||
|
||||
Named by the 2026-09-23 PHP transfer replay. The push path asks every evidence request for change context
|
||||
(`contextEvidence`), and on Paid Memberships Pro that pass ran four portions in 13, 31 and 51 minutes each and
|
||||
exhausted a 7,200 s case, where the census -- the same requests without context -- answers 3,935 in 45 s.
|
||||
|
||||
This builds the PHP graph ONCE, takes the first ``--requests`` taint requests exactly as the scan builds them,
|
||||
and asks them in one invocation per mode. It proves it read its input (files, bytes, graph size, rows) before
|
||||
reporting a time, and it saves every mode's rows so a change to the query can be checked for IDENTICAL output,
|
||||
not merely for speed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openultrasast.cpg.backend import TIMING, JoernBackend
|
||||
from openultrasast.mapping import analyze_entry_points
|
||||
from openultrasast.model.contracts import ChangeContext, ChangedSpan, ExecutionBudget, QuestionIdentity
|
||||
from openultrasast.model.regions import affected_context, regions_for
|
||||
from openultrasast.model.scan import _collect, _grouped, _hook_callbacks
|
||||
from openultrasast.model.shipped import declared_sources
|
||||
from openultrasast.preprocess import build_file_target, enumerate_source_files
|
||||
|
||||
|
||||
def digest(rows: object) -> str:
|
||||
return hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--requests", type=int, default=400)
|
||||
parser.add_argument("--offsets", default="0", help="comma-separated start positions in the scan's request order")
|
||||
parser.add_argument("--modes", default="plain,context")
|
||||
parser.add_argument("--changed", default="", help="a changed file, for the `filtered` mode and the equivalence check")
|
||||
args = parser.parse_args()
|
||||
|
||||
files = enumerate_source_files(args.root)
|
||||
opened = sum(path.stat().st_size for path in files if path.is_file())
|
||||
if not files or not opened:
|
||||
raise SystemExit(f"probe read nothing under {args.root}: {len(files)} files, {opened} bytes")
|
||||
targets = [build_file_target(args.root, path) for path in files]
|
||||
found = regions_for(analyze_entry_points(args.root, targets), targets, shipped=declared_sources(args.root))
|
||||
regions = [r for r in found if r.language == "php"]
|
||||
hooks = _hook_callbacks(args.root, regions)
|
||||
taint = _grouped(_collect(regions), hook_callbacks=hooks).get("taint", {})
|
||||
ordered = list(taint.items())
|
||||
print(f"read {len(files)} files, {opened} bytes; {len(regions)} php regions; {len(taint)} taint requests", flush=True)
|
||||
|
||||
backend = JoernBackend(query_timeout=6 * 3600, execution_budget=ExecutionBudget(time.monotonic() + 8 * 3600, 5.0))
|
||||
started = time.monotonic()
|
||||
graph = backend.build(args.root, language="php")
|
||||
if graph is None:
|
||||
raise SystemExit(f"graph build failed: {backend.last_failure}")
|
||||
size = graph.cpg_path.stat().st_size
|
||||
print(f"graph {size} bytes in {time.monotonic() - started:.1f}s", flush=True)
|
||||
if size < 100_000:
|
||||
raise SystemExit("graph is implausibly small; not measuring an empty graph")
|
||||
|
||||
record: dict[str, object] = {"files": len(files), "bytes": opened, "graph_bytes": size, "requests": args.requests, "modes": {}}
|
||||
answers: dict[str, dict[str, object]] = {}
|
||||
slices = [(int(o), dict(ordered[int(o) : int(o) + args.requests])) for o in args.offsets.split(",")]
|
||||
for (offset, chosen), mode in [(s, m) for s in slices for m in args.modes.split(",")]:
|
||||
mode = f"{mode}@{offset}"
|
||||
files_in = sorted({str(p.get("file", "")) for p in chosen.values()})
|
||||
print(f"{mode}: {len(chosen)} requests over {len(files_in)} files, first {files_in[:3]}", flush=True)
|
||||
extra = {"evidenceOnly": "true", **({"contextEvidence": "true"} if mode.startswith(("context", "filtered")) else {})}
|
||||
if mode.startswith("filtered"):
|
||||
extra.update({"contextFilter": "true", "contextPaths": args.changed})
|
||||
requests = {rid: {**dict(params), **extra} for rid, params in chosen.items()}
|
||||
started = time.monotonic()
|
||||
answer = backend._batch_once(graph.cpg_path, "taint", requests)
|
||||
seconds = time.monotonic() - started
|
||||
if answer is None:
|
||||
raise SystemExit(f"mode {mode}: engine returned nothing after {seconds:.1f}s ({backend.last_failure})")
|
||||
rows = {rid: answer[rid] for rid in requests if rid in answer}
|
||||
costs = sorted((entry["ms"] for entry in answer.get(TIMING, []) if isinstance(entry, dict)), reverse=True)
|
||||
summary = {
|
||||
"seconds": round(seconds, 1),
|
||||
"answered": len(rows),
|
||||
"rows": sum(len(r) for r in rows.values()),
|
||||
"digest": digest(rows),
|
||||
"engine_ms_total": round(sum(costs)),
|
||||
"engine_ms_top5": costs[:5],
|
||||
}
|
||||
record["modes"][mode] = summary # type: ignore[index]
|
||||
(args.out.parent / f"{args.out.stem}-{mode}-rows.json").write_text(json.dumps(rows, sort_keys=True))
|
||||
print(f"{mode}: {json.dumps(summary)}", flush=True)
|
||||
args.out.write_text(json.dumps(record, indent=2) + "\n")
|
||||
answers[mode] = rows
|
||||
# The equivalence that matters: what `affected_context` decides from each form, for a change to `--changed`.
|
||||
for offset, chosen in slices:
|
||||
full, filtered = answers.get(f"context@{offset}"), answers.get(f"filtered@{offset}")
|
||||
if full is None or filtered is None:
|
||||
continue
|
||||
lines = sum(1 for _ in (args.root / args.changed).open(errors="replace"))
|
||||
change = ChangeContext(
|
||||
"base",
|
||||
"head",
|
||||
(args.changed.encode().hex(),),
|
||||
(),
|
||||
(ChangedSpan(args.changed.encode().hex(), 1, lines, "head"),),
|
||||
(),
|
||||
(),
|
||||
(),
|
||||
(),
|
||||
"filesystem-bytes-hex",
|
||||
(),
|
||||
)
|
||||
identities = {rid: QuestionIdentity("unit", "php", str(chosen[rid].get("file", "")), rid, "injection") for rid in chosen}
|
||||
decided = [
|
||||
affected_context(change, list(identities.values()), {identities[rid]: rows[rid] for rid in rows}, args.root)
|
||||
for rows in (full, filtered)
|
||||
]
|
||||
same = decided[0] == decided[1]
|
||||
record[f"equivalent@{offset}"] = {"identical": same, "relationships": len(decided[0][0].relationships), "gaps": len(decided[0][1])}
|
||||
print(f"equivalence@{offset}: {json.dumps(record[f'equivalent@{offset}'])}", flush=True)
|
||||
args.out.write_text(json.dumps(record, indent=2) + "\n")
|
||||
if not same:
|
||||
raise SystemExit("filtered context changed what affected_context decides")
|
||||
if graph.cleanup:
|
||||
graph.cleanup()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -181,7 +181,7 @@ FRONTEND_BUILD_ATTEMPTS = 4
|
||||
# across 2,500 requests made a 42.5MB request file of which 41.7MB was the same string over and over. Joern
|
||||
# parsed that with ujson inside a 2GB heap and the whole batch died in ForkJoinPool, which the driver
|
||||
# correctly reported as `query_failed` for all 2,500 regions -- a whole-repository scan that decided nothing.
|
||||
_SHARED_REQUEST_FIELDS = ("hookCallbacks", "dispatchApply")
|
||||
_SHARED_REQUEST_FIELDS = ("hookCallbacks", "dispatchApply", "contextFilter", "contextPaths")
|
||||
|
||||
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
evidenceOnly: String = "false",
|
||||
hookCallbacks: String = "",
|
||||
dispatchApply: String = "",
|
||||
contextFilter: String = "",
|
||||
contextPaths: String = "",
|
||||
requests: String = "",
|
||||
requestsFile: String = ""
|
||||
) = {
|
||||
@@ -96,7 +98,9 @@
|
||||
fileS: String,
|
||||
depthS: String,
|
||||
boundedS: String,
|
||||
contextS: String = ""
|
||||
contextS: String = "",
|
||||
contextFilterS: String = "",
|
||||
contextPathsS: String = ""
|
||||
): List[ujson.Obj] = {
|
||||
|
||||
// Word-boundary matching, never substring. `resolveUrl` contains `resolve`, so a bare-substring sanitizer
|
||||
@@ -717,21 +721,45 @@
|
||||
"startLine" -> m.lineNumber.getOrElse(-1), "endLine" -> m.lineNumberEnd.getOrElse(-1),
|
||||
"relationship" -> kind)
|
||||
val scoped = cpg.method.filterNot(_.isExternal).filter(m => inScope(m)).l
|
||||
val methods = scoped.map(m => location(m, if (labeledMethods.exists(_.id == m.id)) "entry" else "callee"))
|
||||
// Field carrying is deliberately same-file in the existing abstraction. Retain
|
||||
// its file context conservatively, never claim every method carries the value.
|
||||
val fieldFiles = if (fieldCarriedKind.isEmpty) Set.empty[String] else
|
||||
cpg.call.nameExact(FIELD_ACCESS).filter(c => inScope(c.method)).l.map(_.method.filename).toSet
|
||||
val fields = fieldFiles.toList.flatMap(f => methodsIn(f).filterNot(_.isExternal).map(m => location(m, "field")))
|
||||
val callbacks = if (hookApply.isEmpty) Nil else cpg.call.filter(c => hookApply.contains(c.name))
|
||||
.filter(c => inScope(c.method)).l.flatMap { call =>
|
||||
val hook = call.argument.l.headOption.map(a => unquote(a.code)).getOrElse("")
|
||||
hookTable.getOrElse(hook, Nil).flatMap(name => cpg.method.nameExact(name).filterNot(_.isExternal).l)
|
||||
}.distinct
|
||||
val hooks = callbacks.map(m => location(m, "hook"))
|
||||
// Export sanitizer locations as guard context, not proof of discharge. Removed
|
||||
// guards are retained through base spans and unchanged-line correspondence.
|
||||
val guards = scoped.filter(_.ast.l.exists(sanitizesHere)).map(m => location(m, "guard"))
|
||||
val located: List[(io.shiftleft.codepropertygraph.generated.nodes.Method, String)] =
|
||||
scoped.map(m => m -> (if (labeledMethods.exists(_.id == m.id)) "entry" else "callee")) ++
|
||||
fieldFiles.toList.flatMap(f => methodsIn(f).filterNot(_.isExternal).map(_ -> "field")) ++
|
||||
callbacks.map(_ -> "hook") ++
|
||||
scoped.filter(_.ast.l.exists(sanitizesHere)).map(_ -> "guard")
|
||||
// ONLY THE CHANGED FILES are itemised when the driver says which they are. The consumer attaches a
|
||||
// location to a change only when the location's file is one the change touched, and discards every
|
||||
// other row after checking it names a real file with a sane extent. Itemised anyway, a request whose
|
||||
// scope reaches a vendored SDK returned 1,845 rows, 200 of them 9.4 MB, and the whole-repository pass
|
||||
// on a WordPress plugin never finished a push. Everything else collapses into ONE row carrying exactly
|
||||
// what that check reads: the distinct files and how many locations had no usable extent.
|
||||
val itemise = contextFilterS != "true"
|
||||
// Newline-separated: a path may hold a comma, and the driver never filters when one holds a newline.
|
||||
val changedFiles = contextPathsS.split("\n").map(_.trim).filter(_.nonEmpty).toList
|
||||
def touched(m: io.shiftleft.codepropertygraph.generated.nodes.Method): Boolean =
|
||||
itemise || changedFiles.exists(p => m.filename == p || m.filename.endsWith("/" + p))
|
||||
val (kept, elsewhere) = located.partition { case (m, _) => touched(m) }
|
||||
val locations = kept.map { case (m, relationship) => location(m, relationship) }
|
||||
val elsewhereRows =
|
||||
if (elsewhere.isEmpty) Nil
|
||||
else {
|
||||
val unusable = elsewhere.count { case (m, _) =>
|
||||
val start = m.lineNumber.getOrElse(-1)
|
||||
start < 1 || m.lineNumberEnd.getOrElse(-1) < start
|
||||
}
|
||||
List(ujson.Obj("kind" -> "context_elsewhere", "locations" -> elsewhere.size, "unusableExtents" -> unusable,
|
||||
"paths" -> ujson.Arr.from(elsewhere.map(_._1.filename).distinct.sorted)))
|
||||
}
|
||||
// Reachability remains bounded. External/dynamic calls and calls outside the
|
||||
// current reachability set cannot establish a complete negative for a change.
|
||||
// A modeled argument does not summarize its consumer: unknown(req.body),
|
||||
@@ -761,7 +789,7 @@
|
||||
else if (missing)
|
||||
List(ujson.Obj("kind" -> "context_boundary", "reason" -> "dynamic_external_or_depth_context_unresolved:contributor-scan"))
|
||||
else Nil
|
||||
ujson.Obj("kind" -> "context_summary") :: (methods ++ fields ++ hooks ++ guards ++ boundaries)
|
||||
ujson.Obj("kind" -> "context_summary") :: (locations ++ elsewhereRows ++ boundaries)
|
||||
}
|
||||
return summary :: (perSink ++ contextRows)
|
||||
}
|
||||
@@ -913,7 +941,9 @@
|
||||
field("file"),
|
||||
field("callDepth"),
|
||||
field("boundedSinks"),
|
||||
field("contextEvidence")
|
||||
field("contextEvidence"),
|
||||
if (field("contextFilter").nonEmpty) field("contextFilter") else contextFilter,
|
||||
if (field("contextPaths").nonEmpty) field("contextPaths") else contextPaths
|
||||
): _*
|
||||
)
|
||||
println(ujson.write(ujson.Obj("id" -> id, "rows" -> rows, "ms" -> ((System.nanoTime() - started) / 1000000L).toDouble)))
|
||||
|
||||
@@ -194,6 +194,37 @@ AMBIGUOUS_CORRESPONDENCE = "ambiguous_line_correspondence"
|
||||
REACHABILITY_BOUNDED = "dynamic_external_or_depth_context_unresolved"
|
||||
|
||||
|
||||
def _elsewhere_unusable(row: Mapping[str, object], root: Path, checked: dict[str, bool]) -> bool:
|
||||
"""Whether a summarised set of locations holds one the itemised check would have rejected.
|
||||
|
||||
The same rejections as an itemised `context_method` row: a file that is not a real file under the root,
|
||||
or an extent that is missing or inverted. Each file is checked once per call, because the summary for
|
||||
every family of a region names the same files.
|
||||
"""
|
||||
try:
|
||||
if int(str(row.get("unusableExtents", 0))) > 0:
|
||||
return True
|
||||
except ValueError:
|
||||
return True
|
||||
paths = row.get("paths")
|
||||
if not isinstance(paths, list):
|
||||
return True
|
||||
resolved_root = root.resolve()
|
||||
for raw in paths:
|
||||
text = str(raw)
|
||||
if text not in checked:
|
||||
try:
|
||||
path = Path(text)
|
||||
absolute = path if path.is_absolute() else root / path
|
||||
absolute.resolve().relative_to(resolved_root)
|
||||
checked[text] = absolute.is_file()
|
||||
except (ValueError, OSError):
|
||||
checked[text] = False
|
||||
if not checked[text]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def affected_context(
|
||||
context: ChangeContext,
|
||||
questions: Sequence[QuestionIdentity],
|
||||
@@ -224,6 +255,7 @@ def affected_context(
|
||||
# ambiguous anchor remains uncomparable.
|
||||
inherited_gap = any(not gap.endswith(":" + AMBIGUOUS_CORRESPONDENCE) for gap in gaps)
|
||||
renamed = {context.decode_path(r.base_path): context.decode_path(r.head_path) for r in context.renames}
|
||||
checked: dict[str, bool] = {}
|
||||
for identity in questions:
|
||||
if execution_budget is not None and time.monotonic() >= execution_budget.deadline_monotonic:
|
||||
gaps.append("change_context_deadline_exhausted")
|
||||
@@ -233,7 +265,9 @@ def affected_context(
|
||||
local_gaps = []
|
||||
if not any(r.get("kind") == "context_summary" for r in rows):
|
||||
local_gaps.append("context_projection_unavailable:contributor-scan")
|
||||
elif not any(r.get("kind") == "context_method" for r in rows) and not any(r.get("kind") == "context_boundary" for r in rows):
|
||||
elif not any(r.get("kind") in ("context_method", "context_elsewhere") for r in rows) and not any(
|
||||
r.get("kind") == "context_boundary" for r in rows
|
||||
):
|
||||
# Only when the query offered no account of its own. A query that already said why its
|
||||
# scope is empty -- a named function this file does not define, most often -- has given
|
||||
# the specific reason, and adding a generic one on top buries it.
|
||||
@@ -243,6 +277,13 @@ def affected_context(
|
||||
if row.get("kind") == "context_boundary":
|
||||
reason = str(row.get("reason", "context_unresolved"))
|
||||
(recorded_only if reason.startswith(REACHABILITY_BOUNDED) else local_gaps).append(reason)
|
||||
if row.get("kind") == "context_elsewhere":
|
||||
# Locations in files the change did not touch, summarised by the engine. None of them can be
|
||||
# attached to a change, so the only thing to do with them is what was always done before they
|
||||
# were discarded: check each names a real file under the root and had a usable extent.
|
||||
if _elsewhere_unusable(row, root, checked):
|
||||
local_gaps.append("context_location_unavailable")
|
||||
continue
|
||||
if row.get("kind") != "context_method":
|
||||
continue
|
||||
path = Path(str(row.get("path", "")))
|
||||
|
||||
@@ -340,6 +340,7 @@ def _scan_repository_impl(
|
||||
hooks,
|
||||
degradations=degradations,
|
||||
context_rows=context_rows if change_context is not None else None,
|
||||
context_files=_changed_files(change_context) if change_context is not None else None,
|
||||
unit=unit,
|
||||
)
|
||||
per_kind["evidence"] = round(time.monotonic() - tier_started, 2)
|
||||
@@ -878,6 +879,17 @@ def _sink_weights(
|
||||
return weights
|
||||
|
||||
|
||||
def _changed_files(context: ChangeContext) -> tuple[str, ...] | None:
|
||||
"""The head-side files a context location can be attached to, exactly the set `affected_context` reads.
|
||||
|
||||
``None`` when one cannot be sent intact -- a newline in a path -- so the engine itemises everything.
|
||||
"""
|
||||
changed = {context.decode_path(p) for p in context.changed_paths} | {context.decode_path(r.head_path) for r in context.renames}
|
||||
if any("\n" in path or not path for path in changed):
|
||||
return None
|
||||
return tuple(sorted(changed))
|
||||
|
||||
|
||||
def _request_families(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, str]:
|
||||
"""The family of each request id: the unit the sizer learns a cost rate for."""
|
||||
return {rid: str(getattr(spec, "family", "")) for rid, _region, spec in work}
|
||||
@@ -1224,6 +1236,7 @@ def _evidence_pass(
|
||||
*,
|
||||
degradations: list[Mapping[str, object]] | None = None,
|
||||
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] | None = None,
|
||||
context_files: tuple[str, ...] | None = None,
|
||||
unit: str = "repository",
|
||||
) -> tuple[dict[tuple[str, str, str], Evidence], int]:
|
||||
"""The evidence vector of every taint pair in ``work``, keyed by (path, function, family).
|
||||
@@ -1234,10 +1247,13 @@ def _evidence_pass(
|
||||
grouped_taint = _grouped(list(work), hook_callbacks=hooks).get("taint", {})
|
||||
if not grouped_taint:
|
||||
return {}, 0
|
||||
requests = {
|
||||
rid: {**dict(params), "evidenceOnly": "true", **({"contextEvidence": "true"} if context_rows is not None else {})}
|
||||
for rid, params in grouped_taint.items()
|
||||
}
|
||||
# With change context, the engine itemises locations only in the files the change touched and summarises
|
||||
# the rest, because those are the only locations `affected_context` can attach. `None` means the change's
|
||||
# files cannot be named safely, and then every location is itemised as before.
|
||||
context = {"contextEvidence": "true"} if context_rows is not None else {}
|
||||
if context and context_files is not None:
|
||||
context.update({"contextFilter": "true", "contextPaths": "\n".join(context_files)})
|
||||
requests = {rid: {**dict(params), "evidenceOnly": "true", **context} for rid, params in grouped_taint.items()}
|
||||
# Portioned by request count, because evidence has no per-request weight yet -- it is the pass that
|
||||
# PRODUCES the weights. It is cheap per request, 17,700 in 44 s, and it was still one all-or-nothing
|
||||
# batch: a 22,125-request evidence pass on a plugin's push transaction exceeded its ceiling under load and
|
||||
|
||||
@@ -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