mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 12:39:28 +00:00
feat(model): a region that spans the flow, not just a function
contributor-scan 2.4, closing the no-go task 2.2 recorded. VAmPI's SQL injection is a request parameter reaching `get_by_username` in api_views/users.py, passed to `User.get_user` in models/user_model.py, interpolated into a query string and executed there. Source and sink are in different functions and different modules. Regions were per-function, so neither region saw both ends and the engine was silent on the most ordinary shape a real injection bug has. `reachableByFlows` was never the limitation -- it follows calls already. The limitation was the QUESTION. Sinks were filtered to the labeled function's own body, so a crossing flow had no sink to end at. Three changes, each with a reason it is sound rather than convenient: **An entry point is the trust boundary.** parameter_sources was off for repository scans because a repository's parameters are not all attacker input. An entry point's are, by definition -- that is what makes it an entry point. The driver turns it on only for regions the mapper found and named, and leaves it off for walker fallbacks and for named functions that are not entry points. **Sinks may sit below the handler.** taint.sc admits sinks in methods reachable from the labeled method through the call graph, bounded at ENTRY_POINT_CALL_DEPTH = 3: handler -> service -> data access, which is where these bugs live. Unbounded would make the question about most of the repository rather than about this entry point, and make it unaffordable. **The finding names the sink, not the asker.** A crossing flow ends in another file and another function, so Verdict carries the evidence's own `path:line:function` and the site uses it. Reporting `models/user_model.py:73:get_by_username` would name a place that does not exist: line 73 is inside get_user. Also: labeled methods are now resolved by name AND file. `get_user` exists in more than one module of a real repository, which a corpus of one-file excerpts could never surface. Measured on the same checkout and budget as 2.2: VAmPI-SQLI missed -> model_entailed at models/user_model.py:73:get_user known found 0 of 2 before 2.2's fixes, 1 of 2 in 2.2, 2 of 2 now entailed 7 -> 8 query time 40.9s -> 37.4s peak RSS 869MB -> 827MB cost $0.0067, unchanged Asking the bigger question cost nothing measurable here: file scoping and call-graph following pull in opposite directions and roughly cancel at this size. That is one 520-line repository, and VAmPI is now the repository this change was developed against -- the exact overfitting shape Req 8 exists to catch. The depth bound and the entry-point rule mean nothing general until a checkout nobody tuned against says so. Gates byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
177c7f3719
commit
ca7b5ba370
@@ -18,7 +18,8 @@
|
||||
// cpgFile, sources, sinks, sanitizers (optional), function (optional: restrict to this method AND any
|
||||
// closure lexically nested inside it -- see `nestedInLabeled`),
|
||||
// parameterSources ("true" to treat the labeled function's parameters as untrusted -- see below),
|
||||
// file (optional: restrict sinks to this source file)
|
||||
// file (optional: restrict sinks to this source file),
|
||||
// callDepth (optional: how many call-graph levels below the labeled method may hold a sink)
|
||||
//
|
||||
// `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
|
||||
@@ -43,6 +44,7 @@
|
||||
sanitizers: String = "",
|
||||
function: String = "",
|
||||
file: String = "",
|
||||
callDepth: String = "0",
|
||||
parameterSources: String = "false",
|
||||
requests: String = ""
|
||||
) = {
|
||||
@@ -50,7 +52,7 @@
|
||||
|
||||
def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList
|
||||
|
||||
def rowsFor(sourcesS: String, sinksS: String, sanitizersS: String, functionS: String, paramSrc: String, fileS: String): List[ujson.Obj] = {
|
||||
def rowsFor(sourcesS: String, sinksS: String, sanitizersS: String, functionS: String, paramSrc: String, fileS: String, depthS: String): List[ujson.Obj] = {
|
||||
|
||||
// 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
|
||||
@@ -80,7 +82,36 @@
|
||||
// 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.
|
||||
lazy val labeledMethods = if (function.isEmpty) List.empty else cpg.method.nameExact(function).l
|
||||
// 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
|
||||
}
|
||||
|
||||
def nestedInLabeled(m: io.shiftleft.codepropertygraph.generated.nodes.Method): Boolean =
|
||||
labeledMethods.exists { lm =>
|
||||
@@ -111,8 +142,17 @@
|
||||
val all = cpg.call.filter(c =>
|
||||
sinkNames.exists(n => c.name == n || c.code.startsWith(n + "(") || c.code.startsWith(n) || c.methodFullName.contains(n))
|
||||
)
|
||||
val inFile = if (fileS.isEmpty) all else all.filter(_.method.filename.endsWith(fileS))
|
||||
if (function.isEmpty) inFile else inFile.filter(c => nestedInLabeled(c.method))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
val rows = sinkCalls.l.flatMap { sink =>
|
||||
@@ -137,12 +177,15 @@
|
||||
"sink" -> sink.code.take(200),
|
||||
"sinkLine" -> sink.lineNumber.getOrElse(-1).toString,
|
||||
"sinkMethod" -> sink.method.name,
|
||||
// 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,
|
||||
"source" -> elements.headOption.getOrElse("").take(200),
|
||||
"sanitized" -> sanitized,
|
||||
"length" -> elements.size,
|
||||
"sinkArity" -> arity,
|
||||
"sinkArg0Literal" -> arg0Literal,
|
||||
"inLabeledScope" -> (function.isEmpty || nestedInLabeled(sink.method))
|
||||
"inLabeledScope" -> (function.isEmpty || nestedInLabeled(sink.method) || reachableMethods.contains(sink.method.fullName))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -156,11 +199,11 @@
|
||||
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")
|
||||
id -> ujson.Arr(rowsFor(field("sources"), field("sinks"), field("sanitizers"), field("function"), paramSrc, field("file")): _*)
|
||||
id -> ujson.Arr(rowsFor(field("sources"), field("sinks"), field("sanitizers"), field("function"), paramSrc, field("file"), field("callDepth")): _*)
|
||||
}
|
||||
println(ujson.write(ujson.Obj.from(answers)))
|
||||
} else {
|
||||
println(ujson.write(ujson.Arr(rowsFor(sources, sinks, sanitizers, function, parameterSources, file): _*)))
|
||||
println(ujson.write(ujson.Arr(rowsFor(sources, sinks, sanitizers, function, parameterSources, file, callDepth): _*)))
|
||||
}
|
||||
println("---OUSAST-CPG-END---")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user