mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
188 lines
8.5 KiB
Python
188 lines
8.5 KiB
Python
"""Probe: can a shipped query script run inside a Joern session and answer identically?
|
|||
|
|
|
||
|
|
This settles the one design unknown for release milestone M2 task 11.1. The shipped scripts are
|
||
|
|
`@main def exec(...)` files invoked as `joern --script X.sc --param k=v`, and a session has no
|
||
|
|
`--param`. Before routing production work through a session we need a mechanism that runs the
|
||
|
|
same script with the same parameters and returns the same payload.
|
||
|
|
|
||
|
|
It drives the production `EngineSession` transport, so the receipt protocol under test is the real
|
||
|
|
one. Each candidate defines the script in the session and then calls it, capturing the script's
|
||
|
|
own console output through the session's private receipt file. A candidate counts as working only
|
||
|
|
when its payload matches the disposable invocation's payload for the same graph.
|
||
|
|
|
||
|
|
The first version of this probe sent two candidates as Scala string literals rather than as code
|
||
|
|
and reported no working mechanism. That was an instrument defect, recorded here because a probe
|
||
|
|
that lies about the engine is worse than no probe. It did establish that `runScript` is absent
|
||
|
|
from the server REPL, and it failed loudly, returning the script's own source as its payload
|
||
|
|
rather than quietly reporting a zero.
|
||
|
|
|
||
|
|
Isolated experiment: no production path is changed, and no detection or latency claim is made.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import time
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from openultrasast.cpg.backend import extract_payload
|
||
|
|
from openultrasast.cpg.session import EngineSession
|
||
|
|
from openultrasast.model.contracts import ExecutionBudget
|
||
|
|
|
||
|
|
QUERIES = Path("/app/src/openultrasast/cpg/queries")
|
||
|
|
|
||
|
|
|
||
|
|
def strip_main(source: str, name: str) -> str:
|
||
|
|
"""Turn `@main def exec(...)` into an ordinary definition the REPL can hold."""
|
||
|
|
return source.replace("@main def exec(", f"def {name}(", 1)
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
out = Path(sys.argv[1])
|
||
|
|
out.mkdir(parents=True, exist_ok=False)
|
||
|
|
root = Path(sys.argv[2])
|
||
|
|
record: dict[str, Any] = {
|
||
|
|
"schema_version": 1,
|
||
|
|
"experiment": "session-script-execution-v2",
|
||
|
|
"purpose": "Which in-session mechanism runs a shipped @main script with parameters",
|
||
|
|
"scope": "One JavaScript graph, census script; no security-query equivalence and no latency claim",
|
||
|
|
"instrument_note": (
|
||
|
|
"v1 of this probe sent two candidates as Scala string literals instead of code and reported "
|
||
|
|
"NO_MECHANISM_FOUND. It did establish that runScript is absent from the server REPL."
|
||
|
|
),
|
||
|
|
"attempts": [],
|
||
|
|
"verdict": {},
|
||
|
|
}
|
||
|
|
|
||
|
|
def save() -> None:
|
||
|
|
(out / "result.json").write_text(json.dumps(record, indent=2, default=str) + "\n")
|
||
|
|
|
||
|
|
files = sorted(p for p in root.rglob("*.js") if p.is_file())
|
||
|
|
opened = [(str(p.relative_to(root)), p.read_bytes()) for p in files]
|
||
|
|
if not opened or not all(raw for _, raw in opened):
|
||
|
|
raise SystemExit("no readable JavaScript source under the given root")
|
||
|
|
record["source"] = {
|
||
|
|
"root": str(root),
|
||
|
|
"files": len(opened),
|
||
|
|
"bytes": sum(len(raw) for _, raw in opened),
|
||
|
|
"sha256": hashlib.sha256(b"".join(raw for _, raw in opened)).hexdigest(),
|
||
|
|
}
|
||
|
|
save()
|
||
|
|
|
||
|
|
env = dict(os.environ, JAVA_TOOL_OPTIONS="-Xmx1536m")
|
||
|
|
with tempfile.TemporaryDirectory(prefix="session-script-probe-") as scratch_name:
|
||
|
|
scratch = Path(scratch_name)
|
||
|
|
graph = scratch / "cpg.bin"
|
||
|
|
build = subprocess.run( # noqa: S603
|
||
|
|
["joern-parse", "--output", str(graph), str(root)],
|
||
|
|
cwd=scratch,
|
||
|
|
env=env,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
timeout=900,
|
||
|
|
)
|
||
|
|
graph_bytes = graph.stat().st_size if graph.is_file() else 0
|
||
|
|
record["build"] = {"exit_code": build.returncode, "graph_bytes": graph_bytes, "stderr": build.stderr[-400:]}
|
||
|
|
save()
|
||
|
|
if build.returncode != 0 or not graph_bytes:
|
||
|
|
record["verdict"] = {"status": "INSTRUMENT_FAILURE", "reason": "graph build failed"}
|
||
|
|
save()
|
||
|
|
return 2
|
||
|
|
|
||
|
|
started = time.monotonic()
|
||
|
|
disposable = subprocess.run( # noqa: S603
|
||
|
|
["joern", "--script", str(QUERIES / "census.sc"), "--param", f"cpgFile={graph}"],
|
||
|
|
cwd=scratch,
|
||
|
|
env=env,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
timeout=900,
|
||
|
|
)
|
||
|
|
expected = extract_payload(disposable.stdout or "")
|
||
|
|
record["disposable"] = {"seconds": time.monotonic() - started, "exit_code": disposable.returncode, "payload": expected}
|
||
|
|
save()
|
||
|
|
if not isinstance(expected, dict):
|
||
|
|
record["verdict"] = {"status": "INSTRUMENT_FAILURE", "reason": "disposable census produced no payload"}
|
||
|
|
save()
|
||
|
|
return 2
|
||
|
|
|
||
|
|
source = (QUERIES / "census.sc").read_text()
|
||
|
|
candidates: list[tuple[str, str, str]] = [
|
||
|
|
("main_annotation_kept", source, f"exec({json.dumps(str(graph))})"),
|
||
|
|
("main_annotation_stripped", strip_main(source, "ousastCensus"), f"ousastCensus({json.dumps(str(graph))})"),
|
||
|
|
]
|
||
|
|
|
||
|
|
def fresh(tag: str) -> EngineSession:
|
||
|
|
live = EngineSession(scratch / f"session-{tag}", ExecutionBudget(time.monotonic() + 1500, 5.0), env)
|
||
|
|
live.start()
|
||
|
|
return live
|
||
|
|
|
||
|
|
session = fresh("initial")
|
||
|
|
if not session.alive:
|
||
|
|
record["verdict"] = {"status": "INSTRUMENT_FAILURE", "reason": session.failure}
|
||
|
|
save()
|
||
|
|
session.close()
|
||
|
|
return 2
|
||
|
|
record["session_startup_seconds"] = session.startup_seconds
|
||
|
|
save()
|
||
|
|
try:
|
||
|
|
for index, (name, definition, call) in enumerate(candidates):
|
||
|
|
attempt: dict[str, Any] = {"mechanism": name}
|
||
|
|
started = time.monotonic()
|
||
|
|
# Definitions go to the top level unwrapped: `@main` and top-level declarations
|
||
|
|
# are not block expressions, so the receipt wrapper cannot hold them. The call
|
||
|
|
# that follows is the only proof the definition took.
|
||
|
|
defined = session.define(definition, timeout=300)
|
||
|
|
attempt["definition_sent"] = defined is not None
|
||
|
|
attempt["definition_output"] = (defined or session.failure)[-800:]
|
||
|
|
if defined is not None:
|
||
|
|
answer = session.evaluate(call, timeout=600)
|
||
|
|
if answer is None:
|
||
|
|
attempt["error"] = session.failure
|
||
|
|
# The engine's own diagnostic, without which a missing receipt says nothing.
|
||
|
|
attempt["engine_body"] = session.last_body[-1500:]
|
||
|
|
else:
|
||
|
|
parsed = extract_payload(answer.stdout)
|
||
|
|
attempt["call_seconds"] = answer.seconds
|
||
|
|
attempt["payload"] = parsed
|
||
|
|
attempt["stdout_tail"] = answer.stdout[-300:]
|
||
|
|
attempt["payload_matches_disposable"] = bool(
|
||
|
|
isinstance(parsed, dict)
|
||
|
|
and all(parsed.get(key) == expected.get(key) for key in ("files", "methods", "calls"))
|
||
|
|
and sorted(parsed.get("file_names") or []) == sorted(expected.get("file_names") or [])
|
||
|
|
)
|
||
|
|
attempt["seconds"] = time.monotonic() - started
|
||
|
|
record["attempts"].append(attempt)
|
||
|
|
save()
|
||
|
|
# A poisoned session cannot serve the next candidate honestly.
|
||
|
|
if not session.alive and index + 1 < len(candidates):
|
||
|
|
session.close()
|
||
|
|
session = fresh(name)
|
||
|
|
if not session.alive:
|
||
|
|
break
|
||
|
|
working = [a["mechanism"] for a in record["attempts"] if a.get("payload_matches_disposable")]
|
||
|
|
record["verdict"] = {
|
||
|
|
"status": "PASS" if working else "NO_MECHANISM_FOUND",
|
||
|
|
"working": working,
|
||
|
|
"warm_call_seconds": [a.get("call_seconds") for a in record["attempts"] if a.get("payload_matches_disposable")],
|
||
|
|
"disposable_seconds": record["disposable"]["seconds"],
|
||
|
|
"note": (
|
||
|
|
"Census only. A working mechanism must still be proven equivalent for taint, dominance and "
|
||
|
|
"configuration before any production switch, which is task 11.2."
|
||
|
|
),
|
||
|
|
}
|
||
|
|
save()
|
||
|
|
return 0 if working else 1
|
||
|
|
finally:
|
||
|
|
session.close()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|