2026-09-08 12:23:40 +02:00
|
|
|
// Taint reachability for one flow family (model-grounded-detection Req 6.1).
|
|
|
|
|
//
|
|
|
|
|
// The question this answers is the one our flat IR could not: does an untrusted source reach the labeled sink
|
2026-09-08 12:45:42 +02:00
|
|
|
// *through resolved dataflow*, and is there a sanitizer on that path. `reachableByFlows` walks the PDG, so a
|
|
|
|
|
// value bound several statements upstream is found:
|
2026-09-08 12:23:40 +02:00
|
|
|
//
|
2026-09-08 12:45:42 +02:00
|
|
|
// cmd = request.args["cmd"] request.args["cmd"] -> cmd -> "echo " + cmd -> full
|
|
|
|
|
// full = "echo " + cmd
|
|
|
|
|
// os.system(full)
|
2026-09-08 12:23:40 +02:00
|
|
|
//
|
2026-09-08 12:45:42 +02:00
|
|
|
// That three-hop chain is exactly the one-hop limit which held our measured entailment at 2.2%.
|
|
|
|
|
//
|
|
|
|
|
// Two things here are easy to get backwards and were, first time round:
|
|
|
|
|
// * the sink is the call's ARGUMENTS, not the call node -- data flows into arguments;
|
|
|
|
|
// * `reachableByFlows` is called ON the sink WITH the source, not the other way about.
|
|
|
|
|
//
|
|
|
|
|
// Parameters (comma-separated where plural):
|
2026-09-08 16:24:23 +02:00
|
|
|
// cpgFile, sources, sinks, sanitizers (optional), function (optional: restrict to this method AND any
|
|
|
|
|
// closure lexically nested inside it -- see `nestedInLabeled`),
|
2026-09-09 08:46:21 +02:00
|
|
|
// parameterSources ("true" to treat the labeled function's parameters as untrusted -- see below),
|
2026-09-09 09:27:19 +02:00
|
|
|
// file (optional: restrict sinks to this source file),
|
|
|
|
|
// callDepth (optional: how many call-graph levels below the labeled method may hold a sink)
|
2026-09-09 08:46:21 +02:00
|
|
|
//
|
|
|
|
|
// `file` is what makes a region with no enclosing function askable at all. Without it such a region sends
|
|
|
|
|
// function="" and matches every sink in the repository, and the driver attributes that whole answer to
|
|
|
|
|
// whichever region asked. Scoping by file also stops a named function matching a same-named function in
|
|
|
|
|
// another module, which a corpus of one-file excerpts could never surface.
|
2026-09-08 12:45:42 +02:00
|
|
|
//
|
|
|
|
|
// Output: a fenced JSON array of {sink, sinkLine, sinkMethod, source, sanitized, length}. The fence exists
|
|
|
|
|
// because Joern prints a banner, pass logs and a prompt around whatever a script emits.
|
2026-09-08 12:23:40 +02:00
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
// BATCHED. `requests` is a JSON object of {id: {sources, sinks, sanitizers, function, parameterSources}} and
|
|
|
|
|
// the output is {id: [row, ...]}. One invocation answers a whole scan's questions, because the JVM start
|
|
|
|
|
// (~30s) dwarfs the queries themselves once the CPG is loaded -- one call per region per family put a
|
|
|
|
|
// ten-line file at four minutes and a thousand regions at roughly fifty hours.
|
|
|
|
|
//
|
|
|
|
|
// The single-request form is kept below it: the pair harnesses and every measurement committed so far use it,
|
|
|
|
|
// and changing their call shape would silently invalidate comparisons against those artifacts.
|
|
|
|
|
|
2026-09-08 12:56:13 +02:00
|
|
|
@main def exec(
|
|
|
|
|
cpgFile: String,
|
2026-09-09 07:57:35 +02:00
|
|
|
sources: String = "",
|
|
|
|
|
sinks: String = "",
|
2026-09-08 12:56:13 +02:00
|
|
|
sanitizers: String = "",
|
|
|
|
|
function: String = "",
|
2026-09-09 08:46:21 +02:00
|
|
|
file: String = "",
|
2026-09-09 09:27:19 +02:00
|
|
|
callDepth: String = "0",
|
2026-09-09 07:57:35 +02:00
|
|
|
parameterSources: String = "false",
|
|
|
|
|
requests: String = ""
|
2026-09-08 12:56:13 +02:00
|
|
|
) = {
|
2026-09-08 12:23:40 +02:00
|
|
|
importCpg(cpgFile)
|
|
|
|
|
|
2026-09-08 12:45:42 +02:00
|
|
|
def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList
|
2026-09-08 12:23:40 +02:00
|
|
|
|
2026-09-09 09:27:19 +02:00
|
|
|
def rowsFor(sourcesS: String, sinksS: String, sanitizersS: String, functionS: String, paramSrc: String, fileS: String, depthS: String): List[ujson.Obj] = {
|
2026-09-09 07:57:35 +02:00
|
|
|
|
2026-09-08 16:24:23 +02:00
|
|
|
// Word-boundary matching, never substring. `resolveUrl` contains `resolve`, so a bare-substring sanitizer
|
|
|
|
|
// test marked the VULNERABLE flow sanitized and the verdict fell back to a captured `res` that names no
|
|
|
|
|
// attacker input. Same bug class as the dominance query's guard matching; fixed in both.
|
|
|
|
|
def mentionsToken(code: String, tokens: List[String]): Boolean =
|
|
|
|
|
tokens.exists(t =>
|
|
|
|
|
java.util.regex.Pattern
|
|
|
|
|
.compile("(?<![A-Za-z0-9_])" + java.util.regex.Pattern.quote(t) + "(?![A-Za-z0-9_])")
|
|
|
|
|
.matcher(code)
|
|
|
|
|
.find()
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
val sourcePatterns = split(sourcesS)
|
|
|
|
|
val sinkNames = split(sinksS)
|
|
|
|
|
val sanitizerNames = split(sanitizersS)
|
|
|
|
|
val function = functionS
|
|
|
|
|
val parameterSources = paramSrc
|
2026-09-08 12:23:40 +02:00
|
|
|
|
2026-09-08 12:45:42 +02:00
|
|
|
// A source is any call whose code contains one of the patterns. Matching on `code` rather than a method
|
|
|
|
|
// name keeps `request.args["x"]` (an indexAccess over a fieldAccess) and `req.body.name` both reachable
|
|
|
|
|
// without a per-framework rule for each.
|
2026-09-08 12:56:13 +02:00
|
|
|
// Framework sources: a call whose code contains one of the patterns. Matching on `code` rather than a
|
|
|
|
|
// method name keeps `request.args["x"]` (an indexAccess over a fieldAccess) and `req.body.name` both
|
|
|
|
|
// reachable without a per-framework rule for each.
|
|
|
|
|
def frameworkSources = cpg.call.filter(c => sourcePatterns.exists(p => c.code.contains(p)))
|
|
|
|
|
|
2026-09-08 16:24:23 +02:00
|
|
|
// The labeled methods, and the region they lexically own. A closure defined inside the labeled function --
|
|
|
|
|
// the callback passed to `fs.stat`, say -- has its own synthetic method (`<lambda>0`) whose astParentFullName
|
|
|
|
|
// is empty, so nesting is decided by line-range containment within the same file, which is reliable.
|
2026-09-09 09:27:19 +02:00
|
|
|
// Resolved by name AND file when a file is given: `get_user` exists in more than one module of a real
|
|
|
|
|
// repository, and a corpus of one-file excerpts could never show that.
|
|
|
|
|
lazy val labeledMethods = {
|
|
|
|
|
val byName = if (function.isEmpty) List.empty else cpg.method.nameExact(function).l
|
|
|
|
|
if (fileS.isEmpty) byName else byName.filter(_.filename.endsWith(fileS))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The methods a sink may live in. Lexical nesting alone stops at the function boundary, and a real
|
|
|
|
|
// injection bug rarely respects it: VAmPI's is a request parameter reaching a handler, passed to a method
|
|
|
|
|
// in another module, interpolated there and executed. `reachableByFlows` already follows calls; what did
|
|
|
|
|
// not was the QUESTION -- sinks were filtered to the labeled function's own body, so the flow had no sink
|
|
|
|
|
// to end at and the engine was silent.
|
|
|
|
|
//
|
|
|
|
|
// Bounded by `callDepth`, because "every method transitively reachable from an entry point" is most of a
|
|
|
|
|
// repository, and an unbounded question is how this becomes slow again.
|
|
|
|
|
val depth = scala.util.Try(depthS.toInt).getOrElse(0)
|
|
|
|
|
lazy val reachableMethods: Set[String] =
|
|
|
|
|
if (depth <= 0 || labeledMethods.isEmpty) Set.empty
|
|
|
|
|
else {
|
|
|
|
|
var frontier = labeledMethods.toSet
|
|
|
|
|
var seen = frontier.map(_.fullName)
|
|
|
|
|
var level = 0
|
|
|
|
|
while (level < depth && frontier.nonEmpty) {
|
|
|
|
|
val next = frontier.flatMap(_.callee.l).filterNot(m => seen.contains(m.fullName))
|
|
|
|
|
seen = seen ++ next.map(_.fullName)
|
|
|
|
|
frontier = next
|
|
|
|
|
level += 1
|
|
|
|
|
}
|
|
|
|
|
seen
|
|
|
|
|
}
|
2026-09-08 16:24:23 +02:00
|
|
|
|
|
|
|
|
def nestedInLabeled(m: io.shiftleft.codepropertygraph.generated.nodes.Method): Boolean =
|
|
|
|
|
labeledMethods.exists { lm =>
|
|
|
|
|
m.name == function || (
|
|
|
|
|
m.filename == lm.filename &&
|
|
|
|
|
m.lineNumber.getOrElse(-1) >= lm.lineNumber.getOrElse(0) &&
|
|
|
|
|
m.lineNumberEnd.getOrElse(-1) <= lm.lineNumberEnd.getOrElse(Int.MaxValue)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parameter sources: the labeled function's OWN parameters, never a nested closure's. In a function-level
|
|
|
|
|
// pair the function boundary IS the trust boundary -- the corpus is built so the labeled function's inputs
|
|
|
|
|
// are attacker-controlled -- and 39 of the injection slice's 50 pairs carry no framework token at all. Our
|
|
|
|
|
// flat-IR baseline counted these (`source_kinds: ["parameter"]`), so the comparison is only like-for-like
|
|
|
|
|
// with them on. Scoping matters as much as enabling: a callback's own parameters (`err`, `stats`) are not
|
|
|
|
|
// attacker input, and counting them produced flows like `res -> readFileSync` that name no real source.
|
2026-09-08 12:56:13 +02:00
|
|
|
// Off by default: outside a labeled function, treating every parameter as untrusted is not sound.
|
|
|
|
|
def parameterNodes =
|
|
|
|
|
if (parameterSources != "true") Iterator.empty
|
|
|
|
|
else if (function.isEmpty) cpg.method.parameter.iterator
|
|
|
|
|
else cpg.method.nameExact(function).parameter.iterator
|
|
|
|
|
|
|
|
|
|
def sourceNodes = frameworkSources.l.iterator ++ parameterNodes
|
2026-09-08 12:23:40 +02:00
|
|
|
|
2026-09-08 12:45:42 +02:00
|
|
|
// A sink call: matched by short name (`system`), by leading code (`os.system(...)`), or by resolved
|
|
|
|
|
// full name (`os.py:<module>.system`), so both bare and dotted forms in the spec hit.
|
|
|
|
|
def sinkCalls = {
|
|
|
|
|
val all = cpg.call.filter(c =>
|
|
|
|
|
sinkNames.exists(n => c.name == n || c.code.startsWith(n + "(") || c.code.startsWith(n) || c.methodFullName.contains(n))
|
|
|
|
|
)
|
2026-09-09 09:27:19 +02:00
|
|
|
if (function.isEmpty) {
|
|
|
|
|
// A region with no enclosing function: the file IS the scope.
|
|
|
|
|
if (fileS.isEmpty) all else all.filter(_.method.filename.endsWith(fileS))
|
|
|
|
|
} else if (depth > 0) {
|
|
|
|
|
// Following the call graph means leaving the region's file on purpose, so the file filter would
|
|
|
|
|
// contradict the point. The reachable set is the scope, and the row reports where the sink landed.
|
|
|
|
|
all.filter(c => nestedInLabeled(c.method) || reachableMethods.contains(c.method.fullName))
|
|
|
|
|
} else {
|
|
|
|
|
val inFile = if (fileS.isEmpty) all else all.filter(_.method.filename.endsWith(fileS))
|
|
|
|
|
inFile.filter(c => nestedInLabeled(c.method))
|
|
|
|
|
}
|
2026-09-08 12:23:40 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-08 12:45:42 +02:00
|
|
|
val rows = sinkCalls.l.flatMap { sink =>
|
|
|
|
|
// Data flows into the arguments; asking the call node itself finds nothing.
|
2026-09-08 12:51:42 +02:00
|
|
|
// `call.argument` includes the RECEIVER at argumentIndex 0 (`db` in `db.execute(...)`), so counting it
|
|
|
|
|
// makes a one-argument interpolated call look like a two-argument bound one -- the exact inversion of the
|
|
|
|
|
// test. Real arguments start at index 1.
|
|
|
|
|
val realArgs = sink.argument.argumentIndexGt(0).l
|
|
|
|
|
val flows = sink.argument.reachableByFlows(sourceNodes).l
|
|
|
|
|
|
|
|
|
|
// The SHAPE of the sink call, which is what distinguishes a fix from a bug when the fix is a safe form
|
|
|
|
|
// rather than a sanitizing call: `execute(sql, params)` binds where `execute(sql + x)` interpolates, and
|
|
|
|
|
// `printf("literal", x)` is safe where `printf(userFmt)` is not. The taint path is identical in both, so
|
|
|
|
|
// without these two fields the model entails a pair's fixed side exactly as readily as its vulnerable one.
|
|
|
|
|
val arity = realArgs.size
|
|
|
|
|
val arg0Literal = realArgs.headOption.map(a => a.isLiteral).getOrElse(false)
|
|
|
|
|
|
2026-09-08 12:23:40 +02:00
|
|
|
flows.map { flow =>
|
|
|
|
|
val elements = flow.elements.map(_.code).l
|
2026-09-08 16:24:23 +02:00
|
|
|
val sanitized = sanitizerNames.nonEmpty && elements.exists(code => mentionsToken(code, sanitizerNames))
|
2026-09-08 12:23:40 +02:00
|
|
|
ujson.Obj(
|
2026-09-08 12:51:42 +02:00
|
|
|
"sink" -> sink.code.take(200),
|
|
|
|
|
"sinkLine" -> sink.lineNumber.getOrElse(-1).toString,
|
|
|
|
|
"sinkMethod" -> sink.method.name,
|
2026-09-09 09:27:19 +02:00
|
|
|
// Where the sink actually is. With callDepth > 0 that need not be the region's own file, and a
|
|
|
|
|
// finding reported against the handler's file when the bug is in another module is unactionable.
|
|
|
|
|
"sinkFile" -> sink.method.filename,
|
2026-09-08 12:51:42 +02:00
|
|
|
"source" -> elements.headOption.getOrElse("").take(200),
|
|
|
|
|
"sanitized" -> sanitized,
|
|
|
|
|
"length" -> elements.size,
|
|
|
|
|
"sinkArity" -> arity,
|
2026-09-08 16:24:23 +02:00
|
|
|
"sinkArg0Literal" -> arg0Literal,
|
2026-09-09 09:27:19 +02:00
|
|
|
"inLabeledScope" -> (function.isEmpty || nestedInLabeled(sink.method) || reachableMethods.contains(sink.method.fullName))
|
2026-09-08 12:23:40 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
rows
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 12:23:40 +02:00
|
|
|
println("---OUSAST-CPG-BEGIN---")
|
2026-09-09 07:57:35 +02:00
|
|
|
if (requests.nonEmpty) {
|
|
|
|
|
val parsed = ujson.read(requests).obj
|
|
|
|
|
val answers = parsed.map { case (id, req) =>
|
|
|
|
|
def field(name: String): String = req.obj.get(name).map(_.str).getOrElse("")
|
|
|
|
|
val paramSrc = req.obj.get("parameterSources").map(_.str).getOrElse("false")
|
2026-09-09 09:27:19 +02:00
|
|
|
id -> ujson.Arr(rowsFor(field("sources"), field("sinks"), field("sanitizers"), field("function"), paramSrc, field("file"), field("callDepth")): _*)
|
2026-09-09 07:57:35 +02:00
|
|
|
}
|
|
|
|
|
println(ujson.write(ujson.Obj.from(answers)))
|
|
|
|
|
} else {
|
2026-09-09 09:27:19 +02:00
|
|
|
println(ujson.write(ujson.Arr(rowsFor(sources, sinks, sanitizers, function, parameterSources, file, callDepth): _*)))
|
2026-09-09 07:57:35 +02:00
|
|
|
}
|
2026-09-08 12:23:40 +02:00
|
|
|
println("---OUSAST-CPG-END---")
|
|
|
|
|
}
|