codebase-explorer 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cbe/__init__.py +9 -0
- cbe/__main__.py +4 -0
- cbe/aggregation.py +183 -0
- cbe/api_completion.py +410 -0
- cbe/batching.py +283 -0
- cbe/catalog_query.py +376 -0
- cbe/checks.py +390 -0
- cbe/cli.py +434 -0
- cbe/concrete_facts.py +321 -0
- cbe/data/LICENSE.tiktoken +30 -0
- cbe/data/o200k_base.tiktoken +199998 -0
- cbe/declarations.py +537 -0
- cbe/docs.py +929 -0
- cbe/generate.py +316 -0
- cbe/grammars.py +741 -0
- cbe/graph.py +857 -0
- cbe/graph_backends/__init__.py +43 -0
- cbe/graph_backends/_calls.py +262 -0
- cbe/graph_backends/_paths.py +42 -0
- cbe/graph_backends/identity.py +93 -0
- cbe/graph_backends/imports.py +256 -0
- cbe/graph_backends/joern.py +1021 -0
- cbe/graph_backends/resolution.py +288 -0
- cbe/graph_backends/toolchain.py +396 -0
- cbe/grounding.py +57 -0
- cbe/host_run.py +1752 -0
- cbe/model_settings.py +80 -0
- cbe/projection.py +305 -0
- cbe/prompts.py +316 -0
- cbe/records.py +200 -0
- cbe/skeleton.py +474 -0
- cbe/source_context.py +709 -0
- cbe/symbols.py +136 -0
- cbe/token_budget.py +68 -0
- cbe/treename.py +398 -0
- cbe/usage.py +300 -0
- cbe_install/__init__.py +11 -0
- cbe_install/cli.py +434 -0
- cbe_install/resources/agents/cbe-writer.md +16 -0
- cbe_install/resources/codebase-explorer/SKILL.md +101 -0
- cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
- cbe_install/resources/codebase-explorer/references/codex.md +37 -0
- codebase_explorer-2.2.0.dist-info/METADATA +233 -0
- codebase_explorer-2.2.0.dist-info/RECORD +47 -0
- codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
- codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
- codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
"""Joern backend for the ``cbe-graph/1`` contract.
|
|
2
|
+
|
|
3
|
+
This module wraps the upstream Joern CLI. It does not re-implement any
|
|
4
|
+
language analysis: it runs ``joern-parse`` once per frontend flag, then a
|
|
5
|
+
small CPGQL export script that emits targeted JSON (methods, calls, control
|
|
6
|
+
flow, native REACHING_DEF definition-use edges, import declarations, and
|
|
7
|
+
optionally requested value-flow paths).
|
|
8
|
+
|
|
9
|
+
Identity rules:
|
|
10
|
+
|
|
11
|
+
* Caller/callee/CFG identity comes from Joern's native node ids, not from
|
|
12
|
+
names. Two files that both define ``helper`` cannot be confused.
|
|
13
|
+
* A call with several callees keeps the alternatives and reports
|
|
14
|
+
``ambiguous`` instead of inventing a single target.
|
|
15
|
+
* Data-flow identity is the native ``REACHING_DEF`` edge, not a
|
|
16
|
+
parameter→return path. Requested value-flow paths are a separate, clearly
|
|
17
|
+
scoped, sampled query and are never labelled as intra-procedural analysis.
|
|
18
|
+
* Analysis failures are recorded as diagnostics/errors. A thrown analyzer
|
|
19
|
+
error is never converted into an apparently successful empty graph.
|
|
20
|
+
|
|
21
|
+
Provenance kept per relation: backend name/version, the language frontend and
|
|
22
|
+
the raw expression.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
from collections import Counter
|
|
31
|
+
import shlex
|
|
32
|
+
import subprocess
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from .identity import repository_files, file_id
|
|
36
|
+
from ._calls import CallResolver
|
|
37
|
+
from .resolution import go_modules
|
|
38
|
+
from .toolchain import MIN_JAVA_MAJOR, JavaInstall, find_joern, java_environment, setup_hint # noqa: F401
|
|
39
|
+
|
|
40
|
+
#: language id (canonical, matches ``cbe.source_context``) -> Joern frontend
|
|
41
|
+
#: flag. ``pythonsrc``/``javasrc`` are the source frontends; ``python``/``java``
|
|
42
|
+
#: select the removed py2cpg and the bytecode jimple frontend and are wrong for
|
|
43
|
+
#: source trees.
|
|
44
|
+
JOERN_LANGUAGE_FLAGS: dict[str, str] = {
|
|
45
|
+
"c": "c",
|
|
46
|
+
"cpp": "c",
|
|
47
|
+
"python": "pythonsrc",
|
|
48
|
+
"javascript": "javascript",
|
|
49
|
+
"typescript": "javascript",
|
|
50
|
+
"tsx": "javascript",
|
|
51
|
+
"java": "javasrc",
|
|
52
|
+
"go": "golang",
|
|
53
|
+
"ruby": "rubysrc",
|
|
54
|
+
"rust": "rust",
|
|
55
|
+
"php": "php",
|
|
56
|
+
"c_sharp": "csharpsrc",
|
|
57
|
+
"kotlin": "kotlin",
|
|
58
|
+
"swift": "swiftsrc",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#: Joern frontends that are intentionally outside the source-context surface
|
|
62
|
+
#: because they consume bytecode or binaries, not source.
|
|
63
|
+
JOERN_BYTECODE_FRONTENDS = frozenset({"jimple", "ghidra", "llvm"})
|
|
64
|
+
|
|
65
|
+
#: frontend flag -> Joern frontend name, for provenance.
|
|
66
|
+
FRONTEND_NAME = {
|
|
67
|
+
"c": "c2cpg", "pythonsrc": "pysrc2cpg", "javascript": "jssrc2cpg",
|
|
68
|
+
"javasrc": "javasrc2cpg", "golang": "gosrc2cpg", "rubysrc": "rubysrc2cpg",
|
|
69
|
+
"rust": "rust2cpg", "php": "php2cpg", "csharpsrc": "csharpsrc2cpg",
|
|
70
|
+
"kotlin": "kotlin2cpg", "swiftsrc": "swiftsrc2cpg",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#: Syntax noise that is not a user-visible call relation.
|
|
74
|
+
_SYNTHETIC_CALL = re.compile(r"^(?:<operators?>|__ecma\.|_?tmp\d+\.)")
|
|
75
|
+
|
|
76
|
+
#: REACHING_DEF is capped so a very large program cannot exhaust the run dir.
|
|
77
|
+
DEFAULT_MAX_REACHING_DEFS = 50000
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_EXPORT_SCRIPT = r"""
|
|
81
|
+
import io.shiftleft.codepropertygraph.generated.nodes.*
|
|
82
|
+
import io.joern.dataflowengineoss.language.*
|
|
83
|
+
import io.joern.dataflowengineoss.queryengine.EngineConfig
|
|
84
|
+
import java.nio.file.{Files, Paths}
|
|
85
|
+
import scala.util.{Try, Success, Failure}
|
|
86
|
+
|
|
87
|
+
@main def main(cpgFile: String, outFile: String, maxReachingDefs: Int,
|
|
88
|
+
pathMethods: String, maxPathsPerMethod: Int, maxMethods: Int, withDataflow: Int): Unit = {
|
|
89
|
+
val cpg = importCpg(cpgFile).get
|
|
90
|
+
implicit val engineConfig: EngineConfig = EngineConfig()
|
|
91
|
+
|
|
92
|
+
val realMethods: List[Method] = cpg.method.filterNot(m => m.isExternal || m.name == "<global>").l
|
|
93
|
+
|
|
94
|
+
// Owner method of every AST node, from the AST itself. This covers
|
|
95
|
+
// parameters, parameter-out and method-return nodes uniformly.
|
|
96
|
+
val ownerByNode: Map[Long, String] =
|
|
97
|
+
realMethods.flatMap(m => m.ast.map(n => n.id -> m.fullName)).toMap
|
|
98
|
+
|
|
99
|
+
def fileOf(n: AstNode): String =
|
|
100
|
+
try n.file.name.headOption.getOrElse("") catch { case _: Throwable => "" }
|
|
101
|
+
def ownerOf(n: AstNode): String = ownerByNode.getOrElse(n.id, "")
|
|
102
|
+
def elemJson(n: AstNode): ujson.Obj = ujson.Obj(
|
|
103
|
+
"label" -> n.label,
|
|
104
|
+
"line" -> n.lineNumber.getOrElse(-1),
|
|
105
|
+
"column" -> n.columnNumber.getOrElse(-1),
|
|
106
|
+
"code" -> n.code.linesIterator.toList.headOption.getOrElse(""),
|
|
107
|
+
"file" -> fileOf(n),
|
|
108
|
+
"ownerMethod" -> ownerOf(n),
|
|
109
|
+
"nodeId" -> n.id
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
val files = cpg.file.map(f => ujson.Obj("name" -> f.name, "hash" -> f.hash.getOrElse(""))).l
|
|
113
|
+
|
|
114
|
+
val types = cpg.typeDecl.filterNot(_.isExternal).map { t =>
|
|
115
|
+
ujson.Obj("name" -> t.name, "fullName" -> t.fullName,
|
|
116
|
+
"file" -> t.filename, "inherits" -> t.inheritsFromTypeFullName.toList)
|
|
117
|
+
}.l
|
|
118
|
+
|
|
119
|
+
val methods = realMethods.map { m =>
|
|
120
|
+
ujson.Obj(
|
|
121
|
+
"name" -> m.name,
|
|
122
|
+
"fullName" -> m.fullName,
|
|
123
|
+
"file" -> m.filename,
|
|
124
|
+
"line" -> m.lineNumber.getOrElse(-1),
|
|
125
|
+
"signature" -> m.signature,
|
|
126
|
+
"nodeId" -> m.id
|
|
127
|
+
)
|
|
128
|
+
}.l
|
|
129
|
+
|
|
130
|
+
var callsWithoutOwner = 0
|
|
131
|
+
val callsWithoutOwnerExamples = scala.collection.mutable.ArrayBuffer[ujson.Obj]()
|
|
132
|
+
val calls = cpg.call.filter { c =>
|
|
133
|
+
val owner = c.method
|
|
134
|
+
if (owner == null) {
|
|
135
|
+
callsWithoutOwner += 1
|
|
136
|
+
if (callsWithoutOwnerExamples.size < 5) {
|
|
137
|
+
callsWithoutOwnerExamples += ujson.Obj(
|
|
138
|
+
"file" -> fileOf(c), "line" -> c.lineNumber.getOrElse(-1),
|
|
139
|
+
"code" -> c.code, "nodeId" -> c.id)
|
|
140
|
+
}
|
|
141
|
+
false
|
|
142
|
+
} else !owner.isExternal
|
|
143
|
+
}.map { c =>
|
|
144
|
+
val callees = c.callee.map(m => ujson.Obj(
|
|
145
|
+
"nodeId" -> m.id,
|
|
146
|
+
"fullName" -> m.fullName,
|
|
147
|
+
"external" -> m.isExternal,
|
|
148
|
+
"file" -> m.filename,
|
|
149
|
+
"line" -> m.lineNumber.getOrElse(-1)
|
|
150
|
+
)).l
|
|
151
|
+
ujson.Obj(
|
|
152
|
+
"caller" -> c.method.fullName,
|
|
153
|
+
"callerId" -> c.method.id,
|
|
154
|
+
"callerFile" -> c.method.filename,
|
|
155
|
+
"line" -> c.lineNumber.getOrElse(-1),
|
|
156
|
+
"column" -> c.columnNumber.getOrElse(-1),
|
|
157
|
+
"name" -> c.name,
|
|
158
|
+
"code" -> c.code,
|
|
159
|
+
"methodFullName" -> c.methodFullName,
|
|
160
|
+
"dispatchType" -> c.dispatchType.toString,
|
|
161
|
+
"callees" -> callees,
|
|
162
|
+
"nodeId" -> c.id
|
|
163
|
+
)
|
|
164
|
+
}.l
|
|
165
|
+
|
|
166
|
+
val cfgMethods = if (maxMethods > 0) realMethods.take(maxMethods) else realMethods
|
|
167
|
+
val cfg = cfgMethods.map { m =>
|
|
168
|
+
val nodes = m.cfgNode.l
|
|
169
|
+
val idx = nodes.zipWithIndex.map { case (n, i) => n.id -> i }.toMap
|
|
170
|
+
val nodeJson = nodes.zipWithIndex.map { case (n, i) =>
|
|
171
|
+
ujson.Obj(
|
|
172
|
+
"i" -> i,
|
|
173
|
+
"label" -> n.label,
|
|
174
|
+
"line" -> n.lineNumber.getOrElse(-1),
|
|
175
|
+
"column" -> n.columnNumber.getOrElse(-1),
|
|
176
|
+
"order" -> n.order,
|
|
177
|
+
"code" -> n.code.linesIterator.toList.headOption.getOrElse(""),
|
|
178
|
+
"file" -> fileOf(n),
|
|
179
|
+
"ownerMethod" -> ownerOf(n),
|
|
180
|
+
"nodeId" -> n.id
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
val edges = nodes.flatMap { n =>
|
|
184
|
+
n.out("CFG").map { o =>
|
|
185
|
+
val oc = o.asInstanceOf[CfgNode]
|
|
186
|
+
ujson.Obj("from" -> idx.getOrElse(n.id, -1), "to" -> idx.getOrElse(oc.id, -1))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
ujson.Obj("method" -> m.fullName, "methodId" -> m.id, "file" -> m.filename,
|
|
190
|
+
"line" -> m.lineNumber.getOrElse(-1), "nodeId" -> m.id,
|
|
191
|
+
"nodes" -> nodeJson, "edges" -> edges)
|
|
192
|
+
}.l
|
|
193
|
+
|
|
194
|
+
val imports = cpg.imports.map { i =>
|
|
195
|
+
val f = i.file.name.headOption.getOrElse("")
|
|
196
|
+
ujson.Obj("file" -> f, "line" -> i.lineNumber.getOrElse(-1),
|
|
197
|
+
"code" -> i.code.linesIterator.toList.headOption.getOrElse(""), "nodeId" -> i.id)
|
|
198
|
+
}.l
|
|
199
|
+
|
|
200
|
+
// Native REACHING_DEF definition-use edges: the bounded base data-flow graph.
|
|
201
|
+
val rdSeen = scala.collection.mutable.Set[(Long, Long)]()
|
|
202
|
+
val rdBuf = scala.collection.mutable.ArrayBuffer[ujson.Obj]()
|
|
203
|
+
var rdOmitted = 0
|
|
204
|
+
(if (withDataflow != 0) realMethods else List[Method]()).foreach { m =>
|
|
205
|
+
m.ast.foreach { n =>
|
|
206
|
+
if (n.label != "METHOD") {
|
|
207
|
+
n.out("REACHING_DEF").foreach { o =>
|
|
208
|
+
val t = o.asInstanceOf[AstNode]
|
|
209
|
+
val key = (n.id, t.id)
|
|
210
|
+
if (!rdSeen.contains(key)) {
|
|
211
|
+
rdSeen.add(key)
|
|
212
|
+
if (rdBuf.size >= maxReachingDefs) rdOmitted += 1
|
|
213
|
+
else rdBuf += ujson.Obj("source" -> elemJson(n), "target" -> elemJson(t))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Optional, explicitly requested value-flow paths. Separate scope; sampled;
|
|
221
|
+
// the full path set is counted so the sample is visible.
|
|
222
|
+
val requested = if (withDataflow != 0) pathMethods.split(",").map(_.trim).filter(_.nonEmpty).toList else List[String]()
|
|
223
|
+
val pathBuf = scala.collection.mutable.ArrayBuffer[ujson.Obj]()
|
|
224
|
+
val errBuf = scala.collection.mutable.ArrayBuffer[ujson.Obj]()
|
|
225
|
+
requested.foreach { name =>
|
|
226
|
+
Try {
|
|
227
|
+
cpg.method.filterNot(_.isExternal).name(name).foreach { m =>
|
|
228
|
+
val paths = m.ast.isReturn.reachableByFlows(m.parameter).l
|
|
229
|
+
paths.take(maxPathsPerMethod).foreach { p =>
|
|
230
|
+
pathBuf += ujson.Obj(
|
|
231
|
+
"method" -> m.fullName, "methodId" -> m.id,
|
|
232
|
+
"totalPaths" -> paths.size, "sampled" -> (paths.size > maxPathsPerMethod),
|
|
233
|
+
"scope" -> "value_flow_path",
|
|
234
|
+
"elements" -> p.elements.toList.map(elemJson)
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} match {
|
|
239
|
+
case Failure(e) => errBuf += ujson.Obj(
|
|
240
|
+
"method" -> name,
|
|
241
|
+
"kind" -> e.getClass.getSimpleName,
|
|
242
|
+
"message" -> Option(e.getMessage).getOrElse(""))
|
|
243
|
+
case Success(_) => ()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
val root = ujson.Obj(
|
|
248
|
+
"backend" -> "joern",
|
|
249
|
+
"cpgFile" -> cpgFile,
|
|
250
|
+
"files" -> files,
|
|
251
|
+
"types" -> types,
|
|
252
|
+
"methods" -> methods,
|
|
253
|
+
"calls" -> calls,
|
|
254
|
+
"callsWithoutOwner" -> callsWithoutOwner,
|
|
255
|
+
"callsWithoutOwnerExamples" -> callsWithoutOwnerExamples.toList,
|
|
256
|
+
"cfg" -> cfg,
|
|
257
|
+
"cfgMethodsOmitted" -> (realMethods.size - cfgMethods.size),
|
|
258
|
+
"imports" -> imports,
|
|
259
|
+
"dataflowRequested" -> (withDataflow != 0),
|
|
260
|
+
"reachingDefs" -> rdBuf.toList,
|
|
261
|
+
"reachingDefsTruncated" -> (rdOmitted > 0),
|
|
262
|
+
"reachingDefsOmitted" -> rdOmitted,
|
|
263
|
+
"dataflowPaths" -> pathBuf.toList,
|
|
264
|
+
"dataflowErrors" -> errBuf.toList
|
|
265
|
+
)
|
|
266
|
+
val writer = Files.newBufferedWriter(Paths.get(outFile), java.nio.charset.StandardCharsets.UTF_8)
|
|
267
|
+
try ujson.writeTo(root, writer, indent = -1) finally writer.close()
|
|
268
|
+
println("WROTE " + outFile)
|
|
269
|
+
}
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class JoernError(RuntimeError):
|
|
274
|
+
"""Raised when the Joern CLI is missing or a whole run fails."""
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _run(argv: list[str], timeout: int, cwd: Path, *, env=None) -> subprocess.CompletedProcess:
|
|
278
|
+
return subprocess.run(argv, cwd=str(cwd), check=False, env=env,
|
|
279
|
+
capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def joern_version(joern_bin: Path, timeout: int, java: JavaInstall | None = None) -> str:
|
|
283
|
+
try:
|
|
284
|
+
completed = _run([str(joern_bin), "--version"], timeout=timeout, cwd=joern_bin.parent,
|
|
285
|
+
env=java_environment(java))
|
|
286
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
287
|
+
return "unknown"
|
|
288
|
+
for line in (completed.stdout + completed.stderr).splitlines():
|
|
289
|
+
if line.strip().lower().startswith("version:"):
|
|
290
|
+
return line.split(":", 1)[1].strip()
|
|
291
|
+
return "unknown"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_with_joern(
|
|
295
|
+
repo: Path,
|
|
296
|
+
run_dir: Path,
|
|
297
|
+
languages: list[str],
|
|
298
|
+
joern_home: Path,
|
|
299
|
+
timeout: int,
|
|
300
|
+
*,
|
|
301
|
+
max_reaching_defs: int = DEFAULT_MAX_REACHING_DEFS,
|
|
302
|
+
dataflow_path_methods: list[str] | None = None,
|
|
303
|
+
max_paths_per_method: int = 5,
|
|
304
|
+
max_methods: int | None = None,
|
|
305
|
+
joern_heap: str | None = None,
|
|
306
|
+
java: JavaInstall | None = None,
|
|
307
|
+
with_dataflow: bool = False,
|
|
308
|
+
) -> dict:
|
|
309
|
+
"""Parse each frontend's languages with Joern and return a normalized fragment.
|
|
310
|
+
|
|
311
|
+
Returns ``{"backend", "nodes", "edges", "capabilities", "diagnostics",
|
|
312
|
+
"raw"}``. Raises :class:`JoernError` only when the CLI itself cannot run a
|
|
313
|
+
frontend; per-language parse/export failures become ``failed`` capabilities
|
|
314
|
+
plus diagnostics so a partial graph stays honest.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
joern_bin = joern_home / "joern"
|
|
318
|
+
parse_bin = joern_home / "joern-parse"
|
|
319
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
script = run_dir / "joern_export.sc"
|
|
321
|
+
script.write_text(_EXPORT_SCRIPT, encoding="utf-8")
|
|
322
|
+
|
|
323
|
+
joern_env = java_environment(java)
|
|
324
|
+
if joern_heap:
|
|
325
|
+
joern_env = dict(joern_env or os.environ)
|
|
326
|
+
# Last -Xmx wins, preserving unrelated caller JVM options.
|
|
327
|
+
joern_env["JAVA_TOOL_OPTIONS"] = (joern_env.get("JAVA_TOOL_OPTIONS", "") + f" -Xmx{joern_heap}").strip()
|
|
328
|
+
version = joern_version(joern_bin, timeout, java)
|
|
329
|
+
roots = [str(repo), str(joern_home)]
|
|
330
|
+
diagnostics: list[dict] = []
|
|
331
|
+
capabilities: list[dict] = []
|
|
332
|
+
nodes: list[dict] = []
|
|
333
|
+
edges: list[dict] = []
|
|
334
|
+
raw: list[dict] = []
|
|
335
|
+
non_call_sites = Counter()
|
|
336
|
+
path_methods = ",".join(dataflow_path_methods or []) if with_dataflow else ""
|
|
337
|
+
files = repository_files(repo)
|
|
338
|
+
|
|
339
|
+
by_language: dict[str, list[str]] = {}
|
|
340
|
+
for language in languages:
|
|
341
|
+
flag = JOERN_LANGUAGE_FLAGS.get(language)
|
|
342
|
+
if flag is None:
|
|
343
|
+
diagnostics.append({
|
|
344
|
+
"path": None, "code": "language_unsupported",
|
|
345
|
+
"message": f"joern backend has no source frontend mapping for language {language!r}",
|
|
346
|
+
})
|
|
347
|
+
capabilities.append(_capability(language, "call", "unsupported", version,
|
|
348
|
+
backend="joern", limitations="no source frontend mapping"))
|
|
349
|
+
continue
|
|
350
|
+
by_language.setdefault(flag, []).append(language)
|
|
351
|
+
|
|
352
|
+
for flag, langs in by_language.items():
|
|
353
|
+
namespace = f"cpg:{flag}"
|
|
354
|
+
cpg = run_dir / f"cpg-{flag}.bin"
|
|
355
|
+
export = run_dir / f"cpg-{flag}.json"
|
|
356
|
+
try:
|
|
357
|
+
parse_argv = [str(parse_bin), str(repo), "--language", flag, "-o", str(cpg)]
|
|
358
|
+
if flag == "rubysrc":
|
|
359
|
+
parse_argv += ["--frontend-args", "--no-default-exclude"]
|
|
360
|
+
parsed = _run(parse_argv, timeout=timeout, cwd=run_dir, env=joern_env)
|
|
361
|
+
except subprocess.TimeoutExpired:
|
|
362
|
+
for language in langs:
|
|
363
|
+
capabilities.extend(_failed_capabilities(language, "joern", "backend_timeout"))
|
|
364
|
+
diagnostics.append({"path": None, "code": "backend_timeout",
|
|
365
|
+
"message": f"joern-parse timed out after {timeout}s for frontend {flag}"})
|
|
366
|
+
continue
|
|
367
|
+
except OSError as exc:
|
|
368
|
+
raise JoernError(f"cannot run joern-parse: {exc}") from exc
|
|
369
|
+
parse_output = (parsed.stdout or "") + "\n" + (parsed.stderr or "")
|
|
370
|
+
if parsed.returncode != 0 or not cpg.exists():
|
|
371
|
+
for language in langs:
|
|
372
|
+
capabilities.extend(_failed_capabilities(language, "joern", "backend_error"))
|
|
373
|
+
message = f"joern-parse failed for frontend {flag} (exit {parsed.returncode})"
|
|
374
|
+
cause = _first_error_line(parse_output, roots)
|
|
375
|
+
if cause:
|
|
376
|
+
message += f": {cause}"
|
|
377
|
+
if java is None:
|
|
378
|
+
message += f"; no Java {MIN_JAVA_MAJOR}+ was found, {setup_hint()}"
|
|
379
|
+
diagnostics.append({"path": None, "code": "backend_error", "message": message})
|
|
380
|
+
continue
|
|
381
|
+
try:
|
|
382
|
+
exported = _run(
|
|
383
|
+
[str(joern_bin), "--script", str(script),
|
|
384
|
+
"--param", f"cpgFile={cpg}", "--param", f"outFile={export}",
|
|
385
|
+
"--param", f"maxMethods={max_methods if max_methods is not None else -1}",
|
|
386
|
+
"--param", f"withDataflow={int(with_dataflow)}",
|
|
387
|
+
"--param", f"maxReachingDefs={max_reaching_defs}",
|
|
388
|
+
"--param", f"pathMethods={path_methods}",
|
|
389
|
+
"--param", f"maxPathsPerMethod={max_paths_per_method}", "--nocolors"],
|
|
390
|
+
timeout=timeout, cwd=run_dir, env=joern_env)
|
|
391
|
+
except subprocess.TimeoutExpired:
|
|
392
|
+
for language in langs:
|
|
393
|
+
capabilities.extend(_failed_capabilities(language, "joern", "backend_timeout"))
|
|
394
|
+
diagnostics.append({"path": None, "code": "backend_timeout",
|
|
395
|
+
"message": f"joern export timed out after {timeout}s for frontend {flag}"})
|
|
396
|
+
continue
|
|
397
|
+
if exported.returncode != 0 or not export.exists():
|
|
398
|
+
for language in langs:
|
|
399
|
+
capabilities.extend(_failed_capabilities(language, "joern", "backend_error"))
|
|
400
|
+
message = f"joern export failed for frontend {flag} (exit {exported.returncode})"
|
|
401
|
+
cause = _first_error_line((exported.stderr or "") + "\n" + (exported.stdout or ""), roots)
|
|
402
|
+
if cause:
|
|
403
|
+
message += f": {cause}"
|
|
404
|
+
diagnostics.append({"path": None, "code": "backend_error", "message": message})
|
|
405
|
+
continue
|
|
406
|
+
try:
|
|
407
|
+
payload = json.loads(export.read_text(encoding="utf-8"))
|
|
408
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
409
|
+
for language in langs:
|
|
410
|
+
capabilities.extend(_failed_capabilities(language, "joern", "backend_error"))
|
|
411
|
+
diagnostics.append({"path": None, "code": "backend_error",
|
|
412
|
+
"message": f"joern export unreadable for frontend {flag}: {exc}"})
|
|
413
|
+
continue
|
|
414
|
+
if flag == "csharpsrc":
|
|
415
|
+
_one_based_lines(payload)
|
|
416
|
+
payload["_languages"] = langs
|
|
417
|
+
payload["_frontend"] = FRONTEND_NAME.get(flag, flag)
|
|
418
|
+
payload["_repo_root"] = str(repo)
|
|
419
|
+
payload["_go_modules"] = go_modules(repo, files)
|
|
420
|
+
payload["_javascript_packages"] = _javascript_packages(repo, files)
|
|
421
|
+
payload["_c_macros"] = sorted(_c_macro_names(repo, files)) if flag == "c" else []
|
|
422
|
+
_attach_call_source_lines(payload, repo)
|
|
423
|
+
raw.append(payload)
|
|
424
|
+
payload["_files"] = files
|
|
425
|
+
f_nodes, f_edges = _normalize(payload, version, namespace, diagnostics=diagnostics)
|
|
426
|
+
nodes.extend(f_nodes)
|
|
427
|
+
edges.extend(f_edges)
|
|
428
|
+
non_call_sites.update(payload.get("_non_call_sites", {}))
|
|
429
|
+
frontend_error = None
|
|
430
|
+
if not payload.get("methods"):
|
|
431
|
+
# joern-parse may have exited 0 after a frontend failed to load the
|
|
432
|
+
# project; recover the real cause instead of reporting empty success.
|
|
433
|
+
frontend_error = _frontend_error_from_direct_run(parse_output, run_dir, timeout, roots, env=joern_env)
|
|
434
|
+
if frontend_error:
|
|
435
|
+
diagnostics.append({"path": None, "code": "frontend_error",
|
|
436
|
+
"message": f"joern frontend {flag} reported an error while "
|
|
437
|
+
f"producing no methods: {frontend_error}"})
|
|
438
|
+
for language in langs:
|
|
439
|
+
capabilities.extend(_capabilities_for(language, payload, version,
|
|
440
|
+
frontend_error=frontend_error))
|
|
441
|
+
for error in payload.get("dataflowErrors", []):
|
|
442
|
+
diagnostics.append({"path": None, "code": "dataflow_path_error",
|
|
443
|
+
"message": f"value-flow path query failed for {error.get('method')}: "
|
|
444
|
+
f"{error.get('kind')}: {error.get('message')}"})
|
|
445
|
+
omitted = payload.get("cfgMethodsOmitted", 0)
|
|
446
|
+
if omitted:
|
|
447
|
+
diagnostics.append({"path": None, "code": "cfg_methods_omitted",
|
|
448
|
+
"message": f"CFG export for frontend {flag} omitted {omitted} methods "
|
|
449
|
+
f"(max_methods={max_methods})"})
|
|
450
|
+
if payload.get("reachingDefsTruncated"):
|
|
451
|
+
diagnostics.append({"path": None, "code": "reaching_defs_truncated",
|
|
452
|
+
"edges_omitted": payload.get("reachingDefsOmitted"),
|
|
453
|
+
"message": f"REACHING_DEF export for frontend {flag} hit the "
|
|
454
|
+
f"{max_reaching_defs}-edge cap; "
|
|
455
|
+
f"{payload.get('reachingDefsOmitted', 'unknown')} edges omitted"})
|
|
456
|
+
|
|
457
|
+
# A whole-run CLI failure (OSError) raises above. Per-frontend failures are
|
|
458
|
+
# returned as ``failed`` capabilities + diagnostics so the exact cause
|
|
459
|
+
# reaches the caller instead of a generic "no backend" error.
|
|
460
|
+
if not raw and not (capabilities or diagnostics):
|
|
461
|
+
diagnostics.append({"path": None, "code": "no_language_requested",
|
|
462
|
+
"message": "no Joern source language was requested"})
|
|
463
|
+
return {
|
|
464
|
+
"backend": {"name": "joern", "version": version},
|
|
465
|
+
"nodes": nodes,
|
|
466
|
+
"edges": edges,
|
|
467
|
+
"capabilities": capabilities,
|
|
468
|
+
"diagnostics": diagnostics,
|
|
469
|
+
"non_call_sites": dict(sorted(non_call_sites.items())),
|
|
470
|
+
"raw": raw,
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _c_macro_names(repo: Path, files) -> set[str]:
|
|
475
|
+
"""Read real C/C++ #define nodes with the grammars already used by CBE."""
|
|
476
|
+
from cbe.grammars import _grammar_for_suffix, _make_parser # noqa: PLC0415
|
|
477
|
+
names = set()
|
|
478
|
+
parsers = {}
|
|
479
|
+
for path in sorted(files):
|
|
480
|
+
suffix = Path(path).suffix.lower()
|
|
481
|
+
if suffix not in {".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh"}:
|
|
482
|
+
continue
|
|
483
|
+
spec = _grammar_for_suffix(suffix)
|
|
484
|
+
if spec is None:
|
|
485
|
+
continue
|
|
486
|
+
try:
|
|
487
|
+
parser = parsers.get(spec.name)
|
|
488
|
+
if parser is None:
|
|
489
|
+
parser = parsers[spec.name] = _make_parser(spec)
|
|
490
|
+
data = (repo / path).read_bytes()
|
|
491
|
+
root = parser.parse(data).root_node
|
|
492
|
+
stack = [root]
|
|
493
|
+
while stack:
|
|
494
|
+
node = stack.pop()
|
|
495
|
+
if node.type in {"preproc_def", "preproc_function_def"}:
|
|
496
|
+
name = node.child_by_field_name("name")
|
|
497
|
+
if name is not None:
|
|
498
|
+
names.add(data[name.start_byte:name.end_byte].decode("utf-8"))
|
|
499
|
+
stack.extend(node.children)
|
|
500
|
+
except (OSError, ValueError, RuntimeError, ImportError):
|
|
501
|
+
continue
|
|
502
|
+
return names
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _attach_call_source_lines(payload: dict, repo: Path) -> None:
|
|
506
|
+
"""Add source evidence only where generated calls need disambiguation."""
|
|
507
|
+
cache = {}
|
|
508
|
+
for call in payload.get("calls", []):
|
|
509
|
+
if call.get("name") != "require" or not str(call.get("callerFile", "")).endswith(
|
|
510
|
+
(".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts")):
|
|
511
|
+
continue
|
|
512
|
+
path = _norm_path(call.get("callerFile", ""))
|
|
513
|
+
line = call.get("line")
|
|
514
|
+
if not path or not isinstance(line, int) or line <= 0:
|
|
515
|
+
continue
|
|
516
|
+
if path not in cache:
|
|
517
|
+
try:
|
|
518
|
+
cache[path] = (repo / path).read_text(encoding="utf-8").splitlines()
|
|
519
|
+
except OSError:
|
|
520
|
+
cache[path] = []
|
|
521
|
+
if line <= len(cache[path]):
|
|
522
|
+
statement = []
|
|
523
|
+
for source_line in cache[path][line - 1:line + 19]:
|
|
524
|
+
statement.append(source_line.strip())
|
|
525
|
+
if ";" in source_line:
|
|
526
|
+
break
|
|
527
|
+
call["sourceCode"] = "\n".join(statement)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _javascript_packages(repo: Path, files) -> list[str]:
|
|
531
|
+
names = set()
|
|
532
|
+
for path in sorted(files):
|
|
533
|
+
if Path(path).name != "package.json":
|
|
534
|
+
continue
|
|
535
|
+
try:
|
|
536
|
+
value = json.loads((repo / path).read_text(encoding="utf-8"))
|
|
537
|
+
except (OSError, json.JSONDecodeError):
|
|
538
|
+
continue
|
|
539
|
+
if isinstance(value, dict) and isinstance(value.get("name"), str):
|
|
540
|
+
names.add(value["name"])
|
|
541
|
+
return sorted(names)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _norm_path(raw: str) -> str | None:
|
|
545
|
+
"""Normalize a Joern filename to a repo-relative POSIX path, or ``None``."""
|
|
546
|
+
|
|
547
|
+
from ._paths import canonical_repo_path # noqa: PLC0415
|
|
548
|
+
|
|
549
|
+
return canonical_repo_path(raw)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
_ERROR_MARKERS = re.compile(
|
|
553
|
+
r"(?i)\b(?:error|exception|fail(?:ed|ure)?|not found|no such file|cannot run|unable to)\b")
|
|
554
|
+
_PRIMARY_CAUSE = re.compile(
|
|
555
|
+
r"(?i)(failed to|cannot run|no such file|not found|unable to|are .{0,40}on your path|missing)")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _scrub_text(text: str, roots: list[str]) -> str:
|
|
559
|
+
scrubbed = text
|
|
560
|
+
for root in roots:
|
|
561
|
+
if root:
|
|
562
|
+
scrubbed = scrubbed.replace(str(root), "<path>")
|
|
563
|
+
return scrubbed
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _first_error_line(text: str, roots: list[str]) -> str | None:
|
|
567
|
+
candidates = [line.strip() for line in (text or "").splitlines()
|
|
568
|
+
if (_ERROR_MARKERS.search(line) or "OutOfMemoryError" in line)
|
|
569
|
+
and "process exited with code" not in line.lower()]
|
|
570
|
+
if not candidates:
|
|
571
|
+
return None
|
|
572
|
+
primary = [line for line in candidates if _PRIMARY_CAUSE.search(line)]
|
|
573
|
+
oom = [line for line in candidates if "OutOfMemoryError" in line]
|
|
574
|
+
chosen = max(oom or primary or candidates, key=len)
|
|
575
|
+
return _scrub_text(chosen, roots)[:300]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _frontend_error_from_direct_run(parse_output: str, run_dir: Path, timeout: int,
|
|
579
|
+
roots: list[str], *, env=None) -> str | None:
|
|
580
|
+
"""Re-run the exact frontend command ``joern-parse`` used, to capture its error.
|
|
581
|
+
|
|
582
|
+
``joern-parse`` can exit 0 while a frontend logged a load failure and wrote
|
|
583
|
+
an empty CPG. Only called when an export produced no methods, so a working
|
|
584
|
+
language pays nothing for it.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
match = re.search(r"invoke the frontend:\s*(.+)", parse_output or "")
|
|
588
|
+
if not match:
|
|
589
|
+
return None
|
|
590
|
+
try:
|
|
591
|
+
argv = shlex.split(match.group(1))
|
|
592
|
+
except ValueError:
|
|
593
|
+
return None
|
|
594
|
+
if not argv:
|
|
595
|
+
return None
|
|
596
|
+
try:
|
|
597
|
+
completed = _run(argv, timeout, run_dir, env=env)
|
|
598
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
599
|
+
return None
|
|
600
|
+
return _first_error_line((completed.stdout or "") + "\n" + (completed.stderr or ""), roots)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _non_call_reason(call: dict, imports=(), types=(), macro_names=()) -> str | None:
|
|
604
|
+
"""Classify analyzer-generated call nodes using language-specific facts."""
|
|
605
|
+
name = str(call.get("name", ""))
|
|
606
|
+
code = str(call.get("code", ""))
|
|
607
|
+
full = str(call.get("methodFullName", ""))
|
|
608
|
+
path = str(call.get("callerFile", ""))
|
|
609
|
+
if name.startswith(("<operator>", "<operators>")):
|
|
610
|
+
return "operator" # Joern's node for +, =, [] and the like: never a call site, not counted
|
|
611
|
+
if _SYNTHETIC_CALL.match(name) or full.startswith("ANY.ANY:"):
|
|
612
|
+
return "synthetic"
|
|
613
|
+
if path.endswith((".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh")) \
|
|
614
|
+
and (call.get("dispatchType") == "INLINED" or name in macro_names):
|
|
615
|
+
return "macro"
|
|
616
|
+
cpp_builtin = {"bool", "char", "short", "int", "long", "float", "double",
|
|
617
|
+
"signed", "unsigned", "wchar_t", "char8_t", "char16_t", "char32_t"}
|
|
618
|
+
if path.endswith((".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh")):
|
|
619
|
+
if name in cpp_builtin and full.startswith(name + "." + name + ":"):
|
|
620
|
+
return "type_conversion"
|
|
621
|
+
if full.startswith("struct.struct:") and "{" in code and "}" in code:
|
|
622
|
+
return "literal"
|
|
623
|
+
if path.endswith((".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts")):
|
|
624
|
+
source = str(call.get("sourceCode", ""))
|
|
625
|
+
if name == "require" and (any(imp.get("file") == path and imp.get("line") == call.get("line")
|
|
626
|
+
for imp in imports)
|
|
627
|
+
or re.match(r"^(?:import|export)\b[\s\S]*\bfrom\b", source)):
|
|
628
|
+
return "import_statement"
|
|
629
|
+
if (re.fullmatch(r"_iterator_\d+\.next\(\)", code)
|
|
630
|
+
or re.fullmatch(r"_tmp_\d+\.push\(.*\)", code)):
|
|
631
|
+
return "synthetic"
|
|
632
|
+
if path.endswith(".py"):
|
|
633
|
+
if ("<fakeNew>" in code or "__newInstance.__init__" in code
|
|
634
|
+
or code.startswith("def <body>") or re.fullmatch(r"enter_tmp\d+\(\)", code)
|
|
635
|
+
or code == "__exit__()"):
|
|
636
|
+
return "synthetic"
|
|
637
|
+
if full == "<unknownFullName>" and name in {"__iter__", "__next__"}:
|
|
638
|
+
return "synthetic"
|
|
639
|
+
callees = call.get("callees") or []
|
|
640
|
+
if ((str(call.get("caller", "")).endswith(":<module>")
|
|
641
|
+
or "<metaClassAdapter>" in str(call.get("caller", ""))) and len(callees) == 1
|
|
642
|
+
and not callees[0].get("external")
|
|
643
|
+
and callees[0].get("line") == call.get("line")
|
|
644
|
+
and re.fullmatch(r"[A-Za-z_]\w*\([^)]*\)", code)):
|
|
645
|
+
return "synthetic"
|
|
646
|
+
go_builtin_conversion = (not name and full.endswith(".") and re.match(
|
|
647
|
+
r"^(?:\[\])?(?:string|bool|byte|rune|error|int(?:8|16|32|64)?|uint(?:8|16|32|64)?|uintptr|"
|
|
648
|
+
r"float(?:32|64)|complex(?:64|128))\(", code))
|
|
649
|
+
if go_builtin_conversion:
|
|
650
|
+
return "type_conversion"
|
|
651
|
+
go_literal_type = any(str(value.get("file", "")).endswith(".go")
|
|
652
|
+
and full == str(value.get("fullName", "")) + ".<init>" for value in types)
|
|
653
|
+
if (path.endswith(".go") or go_literal_type) and full.endswith(".<init>") \
|
|
654
|
+
and "{" in code and "}" in code:
|
|
655
|
+
return "literal"
|
|
656
|
+
if path.endswith(".rb"):
|
|
657
|
+
if full == "<unknownFullName>" and code.startswith("defined?("):
|
|
658
|
+
return "synthetic"
|
|
659
|
+
if full == "__core.Array.initialize" and (code.startswith("%w[") or
|
|
660
|
+
(code.startswith("[") and code.endswith("]"))):
|
|
661
|
+
return "literal"
|
|
662
|
+
if path.endswith(".go"):
|
|
663
|
+
go_types = {"string", "bool", "byte", "rune", "error", "int", "int8", "int16",
|
|
664
|
+
"int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64",
|
|
665
|
+
"uintptr", "float32", "float64", "complex64", "complex128"}
|
|
666
|
+
if (name in go_types or (not name and full.endswith("."))
|
|
667
|
+
or any(name == value.get("name") and full == value.get("fullName") for value in types)):
|
|
668
|
+
return "type_conversion"
|
|
669
|
+
if name == "<body>":
|
|
670
|
+
return "synthetic"
|
|
671
|
+
return None
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _visible_call(call: dict, imports=(), types=(), macro_names=()) -> bool:
|
|
675
|
+
return _non_call_reason(call, imports, types, macro_names) is None
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _absent_files(language: str, payload: dict) -> list[str]:
|
|
679
|
+
from cbe.grammars import suffix_languages # noqa: PLC0415
|
|
680
|
+
suffixes = {suffix for suffix, lang in suffix_languages().items() if lang == language}
|
|
681
|
+
present = {_norm_path(method.get("file", "")) for method in payload.get("methods", [])}
|
|
682
|
+
return sorted(path for path in payload.get("_files") or ()
|
|
683
|
+
if Path(path).suffix.lower() in suffixes and path not in present)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _capability(language, graph, status, version, *, backend, limitations=None,
|
|
687
|
+
condition=None, **extra) -> dict:
|
|
688
|
+
entry = {"language": language, "graph": graph, "backend": backend,
|
|
689
|
+
"version": version, "status": status}
|
|
690
|
+
if limitations:
|
|
691
|
+
entry["limitations"] = limitations
|
|
692
|
+
if condition:
|
|
693
|
+
entry["condition"] = condition
|
|
694
|
+
entry.update(extra)
|
|
695
|
+
return entry
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _failed_capabilities(language: str, backend: str, code: str) -> list[dict]:
|
|
699
|
+
return [_capability(language, kind, "failed", None, backend=backend, limitations=code)
|
|
700
|
+
for kind in ("call", "cfg", "dataflow", "import")]
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _one_based_lines(value) -> None:
|
|
704
|
+
"""Joern 4.0's C# frontend numbers lines from 0; every other frontend and CBE from 1.
|
|
705
|
+
|
|
706
|
+
Adds 1 to every known ``line`` of the export in place (a missing line stays -1)."""
|
|
707
|
+
if isinstance(value, dict):
|
|
708
|
+
line = value.get("line")
|
|
709
|
+
if isinstance(line, int) and not isinstance(line, bool) and line >= 0:
|
|
710
|
+
value["line"] = line + 1
|
|
711
|
+
for item in value.values():
|
|
712
|
+
if isinstance(item, (dict, list)):
|
|
713
|
+
_one_based_lines(item)
|
|
714
|
+
elif isinstance(value, list):
|
|
715
|
+
for item in value:
|
|
716
|
+
_one_based_lines(item)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _normalize(payload: dict, version: str, namespace: str = "cpg", *,
|
|
720
|
+
diagnostics: list[dict] | None = None) -> tuple[list[dict], list[dict]]:
|
|
721
|
+
"""Map a raw Joern export payload to standard nodes/edges.
|
|
722
|
+
|
|
723
|
+
Every id is namespaced by the analyzed CPG and keyed on Joern's native
|
|
724
|
+
node id, so same-name methods, overloads and independently numbered
|
|
725
|
+
language CPGs cannot collide.
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
nodes: list[dict] = []
|
|
729
|
+
edges: list[dict] = []
|
|
730
|
+
seen_nodes: set[str] = set()
|
|
731
|
+
source_files = payload.get("_files")
|
|
732
|
+
if source_files is not None:
|
|
733
|
+
from cbe.grammars import suffix_languages
|
|
734
|
+
suffixes = suffix_languages()
|
|
735
|
+
source_files = {p for p in source_files if Path(p).suffix.lower() in suffixes}
|
|
736
|
+
|
|
737
|
+
def add_node(node_id: str, kind: str, *, path=None, start=None, end=None, name=None,
|
|
738
|
+
external=False, owner=None, joern_id=None, label=None, backend="joern") -> None:
|
|
739
|
+
if node_id in seen_nodes:
|
|
740
|
+
return
|
|
741
|
+
seen_nodes.add(node_id)
|
|
742
|
+
if path is not None and source_files is not None and path not in source_files:
|
|
743
|
+
if diagnostics is not None:
|
|
744
|
+
diagnostics.append({"code": "unknown_node_location", "path": None,
|
|
745
|
+
"node": node_id, "analyzer_path": path,
|
|
746
|
+
"message": f"Joern node {node_id} has no source file for {path!r}; location left unknown"})
|
|
747
|
+
path = None
|
|
748
|
+
entry = {"id": node_id, "kind": kind, "path": path, "start_line": start,
|
|
749
|
+
"end_line": end, "name": name, "backend": backend}
|
|
750
|
+
if external:
|
|
751
|
+
entry["external"] = True
|
|
752
|
+
if owner:
|
|
753
|
+
entry["owner"] = owner
|
|
754
|
+
if label:
|
|
755
|
+
entry["label"] = label
|
|
756
|
+
if joern_id is not None:
|
|
757
|
+
entry["analyzer_id"] = joern_id
|
|
758
|
+
nodes.append(entry)
|
|
759
|
+
|
|
760
|
+
method_by_native: dict[int, str] = {}
|
|
761
|
+
for method in payload.get("methods", []):
|
|
762
|
+
rel = _norm_path(method.get("file", ""))
|
|
763
|
+
native = method.get("nodeId")
|
|
764
|
+
node_id = f"{namespace}/method/{native}" if native is not None else \
|
|
765
|
+
f"{namespace}/method/{rel}:{method.get('line', -1)}:{method.get('fullName', '')}"
|
|
766
|
+
full = method.get("fullName") or method.get("name", "")
|
|
767
|
+
add_node(node_id, "method" if "." in full else "function", path=rel,
|
|
768
|
+
start=method.get("line"), end=method.get("line"), name=full,
|
|
769
|
+
joern_id=native)
|
|
770
|
+
if native is not None:
|
|
771
|
+
method_by_native[int(native)] = node_id
|
|
772
|
+
|
|
773
|
+
call_resolver = CallResolver(payload.get("methods", []), payload.get("_files"),
|
|
774
|
+
payload.get("types"), payload.get("_go_modules"),
|
|
775
|
+
payload.get("_javascript_packages"))
|
|
776
|
+
non_call_sites = Counter()
|
|
777
|
+
for call in payload.get("calls", []):
|
|
778
|
+
reason = _non_call_reason(call, payload.get("imports", []), payload.get("types", []),
|
|
779
|
+
payload.get("_c_macros", []))
|
|
780
|
+
if reason:
|
|
781
|
+
if reason != "operator":
|
|
782
|
+
non_call_sites[reason] += 1
|
|
783
|
+
continue
|
|
784
|
+
rel = _norm_path(call.get("callerFile", ""))
|
|
785
|
+
native_caller = call.get("callerId")
|
|
786
|
+
caller = method_by_native.get(int(native_caller)) if native_caller is not None else None
|
|
787
|
+
if caller is None:
|
|
788
|
+
caller = f"{namespace}/caller/{rel}:{call.get('line', -1)}:{call.get('caller', '')}"
|
|
789
|
+
add_node(caller, "function", path=rel, start=call.get("line"), name=call.get("caller"))
|
|
790
|
+
status, native_targets, external_names = call_resolver.resolve(call)
|
|
791
|
+
internal = [method_by_native[native] for native in native_targets]
|
|
792
|
+
externals = []
|
|
793
|
+
for full in external_names:
|
|
794
|
+
eid = f"{namespace}/ext/{full}"
|
|
795
|
+
add_node(eid, "external", name=full, external=True)
|
|
796
|
+
externals.append(eid)
|
|
797
|
+
candidates = internal + externals
|
|
798
|
+
target = candidates[0] if status in {"resolved", "external"} and candidates else None
|
|
799
|
+
edge = {
|
|
800
|
+
"id": f"{namespace}/call/{call.get('nodeId')}",
|
|
801
|
+
"source": caller, "target": target, "kind": "call", "status": status,
|
|
802
|
+
"path": rel, "line": call.get("line"), "column": call.get("column"),
|
|
803
|
+
"expression": call.get("code"),
|
|
804
|
+
"method_full_name": external_names[0] if len(external_names) == 1 else call.get("methodFullName"),
|
|
805
|
+
"analyzer_method_full_name": call.get("methodFullName"),
|
|
806
|
+
"analyzer_id": call.get("nodeId"), "backend": "joern",
|
|
807
|
+
}
|
|
808
|
+
if len(candidates) > 1:
|
|
809
|
+
edge["candidates"] = candidates
|
|
810
|
+
edges.append(edge)
|
|
811
|
+
|
|
812
|
+
for cfg in payload.get("cfg", []):
|
|
813
|
+
method_native = cfg.get("methodId", cfg.get("nodeId"))
|
|
814
|
+
owner_node = method_by_native.get(int(method_native)) if method_native is not None else None
|
|
815
|
+
rel = _norm_path(cfg.get("file", ""))
|
|
816
|
+
index_to_node: dict[int, str] = {}
|
|
817
|
+
for node in cfg.get("nodes", []):
|
|
818
|
+
i = node.get("i")
|
|
819
|
+
node_id = f"{namespace}/cfg/{method_native}/{i}"
|
|
820
|
+
index_to_node[i] = node_id
|
|
821
|
+
add_node(node_id, "cfg_node", path=_norm_path(node.get("file", "")) or rel,
|
|
822
|
+
start=node.get("line"), end=node.get("line"), name=node.get("label"),
|
|
823
|
+
owner=owner_node, label=node.get("label"), joern_id=node.get("nodeId"))
|
|
824
|
+
for edge in cfg.get("edges", []):
|
|
825
|
+
source = index_to_node.get(edge.get("from"))
|
|
826
|
+
target = index_to_node.get(edge.get("to"))
|
|
827
|
+
if source is None or target is None:
|
|
828
|
+
continue
|
|
829
|
+
edges.append({
|
|
830
|
+
"id": f"{namespace}/cfgedge/{method_native}/{edge.get('from')}->{edge.get('to')}",
|
|
831
|
+
"source": source, "target": target, "kind": "cfg", "status": "resolved",
|
|
832
|
+
"path": rel, "owner": owner_node, "backend": "joern",
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
payload["_non_call_sites"] = dict(sorted(non_call_sites.items()))
|
|
836
|
+
|
|
837
|
+
import_declarations = [imp for imp in payload.get("imports", [])
|
|
838
|
+
if imp.get("file") and imp.get("code") not in (None, "", "<empty>")]
|
|
839
|
+
import_keys = {(imp.get("file"), imp.get("line"), imp.get("code")) for imp in import_declarations}
|
|
840
|
+
for call in payload.get("calls", []):
|
|
841
|
+
if (call.get("name") not in {"require", "require_relative"}
|
|
842
|
+
or not str(call.get("callerFile", "")).endswith(".rb")):
|
|
843
|
+
continue
|
|
844
|
+
key = (call.get("callerFile"), call.get("line"), call.get("code"))
|
|
845
|
+
if key not in import_keys:
|
|
846
|
+
import_keys.add(key)
|
|
847
|
+
import_declarations.append({"file": key[0], "line": key[1], "code": key[2],
|
|
848
|
+
"nodeId": f"{call.get('nodeId')}:import"})
|
|
849
|
+
for imp in import_declarations:
|
|
850
|
+
rel = _norm_path(imp.get("file", ""))
|
|
851
|
+
file_node_id = file_id(rel, payload.get("_files", {rel}))
|
|
852
|
+
if file_node_id:
|
|
853
|
+
add_node(file_node_id, "file", path=rel, name=Path(rel).name)
|
|
854
|
+
edges.append({
|
|
855
|
+
"id": f"{namespace}/import/{imp.get('nodeId')}",
|
|
856
|
+
"source": file_node_id, "target": None, "kind": "import", "status": "unresolved",
|
|
857
|
+
"path": rel, "line": imp.get("line"), "expression": imp.get("code"),
|
|
858
|
+
"analyzer_id": imp.get("nodeId"),
|
|
859
|
+
"backend": "joern",
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
def add_flow_node(element: dict) -> str:
|
|
863
|
+
eid = f"{namespace}/dfn/{element.get('nodeId')}"
|
|
864
|
+
add_node(eid, "dataflow_node", path=_norm_path(element.get("file", "")),
|
|
865
|
+
start=element.get("line"), end=element.get("line"),
|
|
866
|
+
name=element.get("label"), owner=element.get("ownerMethod"),
|
|
867
|
+
label=element.get("label"), joern_id=element.get("nodeId"))
|
|
868
|
+
return eid
|
|
869
|
+
|
|
870
|
+
for rd in payload.get("reachingDefs", []):
|
|
871
|
+
source_el = rd.get("source", {})
|
|
872
|
+
target_el = rd.get("target", {})
|
|
873
|
+
source = add_flow_node(source_el)
|
|
874
|
+
target = add_flow_node(target_el)
|
|
875
|
+
same_owner = (source_el.get("ownerMethod") and
|
|
876
|
+
source_el.get("ownerMethod") == target_el.get("ownerMethod"))
|
|
877
|
+
edges.append({
|
|
878
|
+
"id": f"{namespace}/rd/{source_el.get('nodeId')}->{target_el.get('nodeId')}",
|
|
879
|
+
"source": source, "target": target, "kind": "dataflow", "status": "resolved",
|
|
880
|
+
"path": _norm_path(source_el.get("file", "")),
|
|
881
|
+
"scope": "reaching_def_intra" if same_owner else "reaching_def_inter",
|
|
882
|
+
"backend": "joern",
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
for path in payload.get("dataflowPaths", []):
|
|
886
|
+
method_native = path.get("methodId")
|
|
887
|
+
previous = None
|
|
888
|
+
for position, element in enumerate(path.get("elements", [])):
|
|
889
|
+
eid = add_flow_node(element)
|
|
890
|
+
if previous is not None:
|
|
891
|
+
edges.append({
|
|
892
|
+
"id": f"{namespace}/vfp/{method_native}/{position}",
|
|
893
|
+
"source": previous, "target": eid, "kind": "dataflow", "status": "resolved",
|
|
894
|
+
"path": _norm_path(element.get("file", "")), "scope": "value_flow_path",
|
|
895
|
+
"sampled": bool(path.get("sampled")), "backend": "joern",
|
|
896
|
+
})
|
|
897
|
+
previous = eid
|
|
898
|
+
|
|
899
|
+
if diagnostics is not None:
|
|
900
|
+
diagnostics.extend(call_resolver.diagnostics)
|
|
901
|
+
calls_without_owner = payload.get("callsWithoutOwner", 0)
|
|
902
|
+
if calls_without_owner:
|
|
903
|
+
diagnostics.append({
|
|
904
|
+
"path": None, "code": "calls_without_owner_omitted",
|
|
905
|
+
"count": calls_without_owner,
|
|
906
|
+
"examples": payload.get("callsWithoutOwnerExamples", []),
|
|
907
|
+
"message": f"Joern export omitted {calls_without_owner} call nodes with no containing method",
|
|
908
|
+
})
|
|
909
|
+
if payload.get("methods"):
|
|
910
|
+
for language in payload.get("_languages", []):
|
|
911
|
+
absent = _absent_files(language, payload)
|
|
912
|
+
if absent:
|
|
913
|
+
diagnostics.append({"path": None, "code": "files_absent_from_graph",
|
|
914
|
+
"language": language, "count": len(absent),
|
|
915
|
+
"examples": absent[:5],
|
|
916
|
+
"message": f"{len(absent)} {language} manifest files have no method nodes: "
|
|
917
|
+
+ ", ".join(absent[:5])})
|
|
918
|
+
return nodes, edges
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _capabilities_for(language: str, payload: dict, version: str,
|
|
922
|
+
*, frontend_error: str | None = None) -> list[dict]:
|
|
923
|
+
if frontend_error:
|
|
924
|
+
return [_capability(language, kind, "failed", version, backend="joern",
|
|
925
|
+
limitations=f"frontend error: {frontend_error}")
|
|
926
|
+
for kind in ("call", "cfg", "dataflow", "import")]
|
|
927
|
+
calls = [call for call in payload.get("calls", [])
|
|
928
|
+
if _visible_call(call, payload.get("imports", []), payload.get("types", []),
|
|
929
|
+
payload.get("_c_macros", []))]
|
|
930
|
+
non_call_sites = Counter(
|
|
931
|
+
reason for call in payload.get("calls", [])
|
|
932
|
+
if (reason := _non_call_reason(call, payload.get("imports", []), payload.get("types", []),
|
|
933
|
+
payload.get("_c_macros", []))) not in (None, "operator"))
|
|
934
|
+
resolver = CallResolver(payload.get("methods", []), payload.get("_files"),
|
|
935
|
+
payload.get("types"), payload.get("_go_modules"),
|
|
936
|
+
payload.get("_javascript_packages"))
|
|
937
|
+
statuses = [resolver.resolve(call)[0] for call in calls]
|
|
938
|
+
resolved = statuses.count("resolved")
|
|
939
|
+
external = statuses.count("external")
|
|
940
|
+
ambiguous = statuses.count("ambiguous")
|
|
941
|
+
unresolved = len(calls) - resolved - external - ambiguous
|
|
942
|
+
|
|
943
|
+
if resolved and not (ambiguous or unresolved):
|
|
944
|
+
call_status = "resolved"
|
|
945
|
+
elif calls:
|
|
946
|
+
call_status = "partial"
|
|
947
|
+
else:
|
|
948
|
+
call_status = "empty"
|
|
949
|
+
|
|
950
|
+
cfg_methods = sum(1 for cfg in payload.get("cfg", []) if cfg.get("edges"))
|
|
951
|
+
rd_edges = len(payload.get("reachingDefs", []))
|
|
952
|
+
has_methods = bool(payload.get("methods"))
|
|
953
|
+
if not payload.get("dataflowRequested", True):
|
|
954
|
+
df_status = "not_requested"
|
|
955
|
+
elif payload.get("reachingDefsTruncated"):
|
|
956
|
+
df_status = "partial"
|
|
957
|
+
elif rd_edges:
|
|
958
|
+
df_status = "resolved"
|
|
959
|
+
elif has_methods:
|
|
960
|
+
df_status = "empty"
|
|
961
|
+
else:
|
|
962
|
+
df_status = "unsupported"
|
|
963
|
+
|
|
964
|
+
conditions = []
|
|
965
|
+
if language == "cpp":
|
|
966
|
+
conditions.append("virtual/abstract calls bind to the static base declaration")
|
|
967
|
+
if language in ("c", "cpp", "java", "go", "c_sharp"):
|
|
968
|
+
conditions.append("coverage depends on the build/compiler configuration given to the frontend")
|
|
969
|
+
if language in ("javascript", "typescript", "tsx", "python", "ruby"):
|
|
970
|
+
conditions.append("dynamic dispatch and monkey-patching can hide call targets")
|
|
971
|
+
if language == "ruby":
|
|
972
|
+
conditions.append("rubysrc2cpg runs with --no-default-exclude; remaining absent files are diagnosed")
|
|
973
|
+
if language == "rust":
|
|
974
|
+
conditions.append("rust2cpg requires a Cargo project layout (Cargo.toml + src)")
|
|
975
|
+
if language == "php":
|
|
976
|
+
conditions.append("php2cpg requires a PHP >= 7 runtime on PATH")
|
|
977
|
+
if language == "swift":
|
|
978
|
+
conditions.append("swiftsrc2cpg parses files independently; cross-file calls may stay external")
|
|
979
|
+
if language == "c_sharp":
|
|
980
|
+
conditions.append("cross-file resolution needs a shared namespace or project")
|
|
981
|
+
call_notes = []
|
|
982
|
+
if external:
|
|
983
|
+
call_notes.append(f"{external} external")
|
|
984
|
+
if ambiguous:
|
|
985
|
+
call_notes.append(f"{ambiguous} ambiguous (multiple callees preserved)")
|
|
986
|
+
if unresolved:
|
|
987
|
+
call_notes.append(f"{unresolved} unresolved")
|
|
988
|
+
if not calls:
|
|
989
|
+
call_notes.append("analyzer ran but produced no user-visible calls")
|
|
990
|
+
if non_call_sites:
|
|
991
|
+
call_notes.append("excluded non-call sites: " + ", ".join(
|
|
992
|
+
f"{count} {reason}" for reason, count in sorted(non_call_sites.items())))
|
|
993
|
+
absent = _absent_files(language, payload)
|
|
994
|
+
if absent:
|
|
995
|
+
call_notes.append(f"{len(absent)} manifest files have no method nodes")
|
|
996
|
+
|
|
997
|
+
return [
|
|
998
|
+
_capability(language, "call", call_status, version, backend="joern",
|
|
999
|
+
limitations="; ".join(call_notes) or None,
|
|
1000
|
+
condition="; ".join(conditions) or None,
|
|
1001
|
+
resolved=resolved, external=external, ambiguous=ambiguous,
|
|
1002
|
+
unresolved=unresolved, non_call_sites=dict(sorted(non_call_sites.items()))),
|
|
1003
|
+
_capability(language, "cfg", "partial" if payload.get("cfgMethodsOmitted", 0) else ("resolved" if cfg_methods else ("empty" if has_methods else "unsupported")),
|
|
1004
|
+
version, backend="joern", methods_with_edges=cfg_methods,
|
|
1005
|
+
methods_omitted=payload.get("cfgMethodsOmitted", 0),
|
|
1006
|
+
limitations=f"{payload['cfgMethodsOmitted']} methods omitted" if payload.get("cfgMethodsOmitted") else None),
|
|
1007
|
+
_capability(language, "dataflow", df_status, version, backend="joern",
|
|
1008
|
+
scope="reaching_def", reaching_def_edges=rd_edges,
|
|
1009
|
+
reaching_defs_truncated=bool(payload.get("reachingDefsTruncated")),
|
|
1010
|
+
edges_omitted=payload.get("reachingDefsOmitted", None if payload.get("reachingDefsTruncated") else 0),
|
|
1011
|
+
limitations=("dataflow was not exported; rebuild with `cbe graph build --with-dataflow`"
|
|
1012
|
+
if df_status == "not_requested" else
|
|
1013
|
+
f"REACHING_DEF export truncated; {payload.get('reachingDefsOmitted', 'unknown')} edges omitted"
|
|
1014
|
+
if payload.get("reachingDefsTruncated") else
|
|
1015
|
+
(None if rd_edges else "analyzer ran but produced no REACHING_DEF edges")),
|
|
1016
|
+
condition="local reaching definitions; inter-procedural value-flow paths are a separate opt-in query"),
|
|
1017
|
+
_capability(language, "import",
|
|
1018
|
+
"partial" if payload.get("imports") else ("empty" if has_methods else "unsupported"),
|
|
1019
|
+
version, backend="joern",
|
|
1020
|
+
limitations="static import/include declarations; repository resolution applied at graph merge"),
|
|
1021
|
+
]
|