learn: trace-enabled Joern run over labelled units (opt-in trace in taint.sc, trace parsing, resumable runner)

Implemented with Codex. Default scan queries do not request a trace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-03 19:10:47 +02:00
co-authored by Claude Opus 5.5
parent 20777d8863
commit 19c936343b
5 changed files with 1161 additions and 2 deletions
+9 -2
View File
@@ -56,7 +56,8 @@
contextFilter: String = "",
contextPaths: String = "",
requests: String = "",
requestsFile: String = ""
requestsFile: String = "",
trace: String = ""
) = {
importCpg(cpgFile)
@@ -1489,6 +1490,12 @@
// collapsed the sizer to three sink visits per JVM start on the plugin.
val started = System.nanoTime()
def field(name: String): String = req.obj.get(name).map(_.str).getOrElse("")
// Opt-in heartbeat for the trace runner's per-question process watchdog.
// Killing the JVM preserves completed streamed rows without unsafe query threads.
if (field("questionDeadline").nonEmpty) {
println(ujson.write(ujson.Obj("__question__" -> id)))
System.out.flush()
}
val paramSrc = req.obj.get("parameterSources").map(_.str).getOrElse("false")
val fieldParamSrc = req.obj.get("fieldParameterSources").map(_.str).getOrElse(paramSrc)
val fieldSrc = req.obj.get("fieldSources").map(_.str).getOrElse("true")
@@ -1532,7 +1539,7 @@
} else {
println("---OUSAST-CPG-BEGIN---")
println(
ujson.write(ujson.Arr(rowsFor(sources, sinks, sanitizers, hookCallbacks, dispatchApply, dispatchValue, function, parameterSources, parameterSources, fieldSources, evidenceOnly, file, callDepth, boundedSinks): _*))
ujson.write(ujson.Arr(rowsFor(sources, sinks, sanitizers, hookCallbacks, dispatchApply, dispatchValue, function, parameterSources, parameterSources, fieldSources, evidenceOnly, file, callDepth, boundedSinks, traceS = trace): _*))
)
}
println("---OUSAST-CPG-END---")
+2
View File
@@ -28,6 +28,7 @@ def request_params(
call_depth: int = 0,
hook_callbacks: str = "",
field_parameter_sources: bool | None = None,
trace: bool = False,
) -> dict[str, object]:
"""The query parameters this arbiter sends. Shared with the batcher so there is one definition, not two.
@@ -40,6 +41,7 @@ def request_params(
means unscoped, which is what the single-region pair path still uses.
"""
return {
**({"trace": "true"} if trace else {}),
"sources": spec.sources,
"sinks": spec.sinks,
"sanitizers": spec.sanitizers,
+51
View File
@@ -0,0 +1,51 @@
"""Lossless parsing of the optional shortest-path trace, with explicit evidence limits."""
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
_STEP = re.compile(r"^(.*):(-?\d+) \[([^\]]+)\] ([\s\S]*)$")
def parse_trace(row: Mapping[str, Any]) -> dict[str, Any]:
"""Preserve row-level discharge evidence; never guess which node sanitized a path.
The query emits basename-only files and truncated code. A bound can be off-path;
it remains in ``guard`` even when no trace step exactly matches it. Invalid steps
raise instead of turning an unreadable trace into an empty path.
"""
raw = row.get("trace", [])
if not isinstance(raw, list):
raise ValueError("trace must be an array")
steps = []
bound = str(row.get("bound") or "")
for index, text in enumerate(raw):
match = _STEP.fullmatch(text) if isinstance(text, str) else None
if match is None:
raise ValueError(f"invalid trace step: {text!r}")
location, line, node_label, code = match.groups()
file, separator, method = location.partition(":")
roles = ["source"] if index == 0 else ["step"]
if index == len(raw) - 1:
roles = ["source", "sink"] if index == 0 else ["sink"]
if bound and code == bound:
roles.append("guard")
steps.append(
{
"file": file,
"method": method if separator else "?",
"line": int(line),
"node_label": node_label,
"code": code,
"roles": roles,
"raw": text,
}
)
return {
"steps": steps,
"sanitized": bool(row.get("sanitized", False)),
"sanitizer": {"on_path": bool(row.get("sanitized", False)), "step": None},
"guard": {"text": bound, "bounded": bool(row.get("bounded", False))},
}