mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +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
@@ -118,7 +118,7 @@
|
|||||||
- _Requirements: 4.2_
|
- _Requirements: 4.2_
|
||||||
- _Depends: 2.2_
|
- _Depends: 2.2_
|
||||||
|
|
||||||
- [ ] 2.4 A region that spans the flow, not just a function
|
- [x] 2.4 A region that spans the flow, not just a function
|
||||||
- **2.2's finding, and the one that reshapes the phase.** VAmPI-SQLI is a request parameter reaching
|
- **2.2's finding, and the one that reshapes the phase.** VAmPI-SQLI is a request parameter reaching
|
||||||
`get_by_username(username)`, passed to `User.get_user(username)` in another module, interpolated into a
|
`get_by_username(username)`, passed to `User.get_user(username)` in another module, interpolated into a
|
||||||
query and executed. Source and sink are in different functions, regions are per-function, and a
|
query and executed. Source and sink are in different functions, regions are per-function, and a
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"against": "benchmarks/measurements/2026-09-09-repo-vampi.json (task 2.2, same checkout and budget)",
|
||||||
|
"caveat": "Two known vulnerabilities in one 520-line repository is not a detection rate, and VAmPI is now the repository this change was developed against -- exactly the overfitting shape Req 8 exists to catch. The depth-3 bound and the entry-point rule need a checkout nobody tuned against before they mean anything general.",
|
||||||
|
"completed": true,
|
||||||
|
"config": "shipped defaults",
|
||||||
|
"degradations": [
|
||||||
|
{
|
||||||
|
"detail": "no path records; obligations evaluated within entry-point handlers only",
|
||||||
|
"files": [
|
||||||
|
"api_views/books.py",
|
||||||
|
"api_views/users.py",
|
||||||
|
"models/books_model.py",
|
||||||
|
"models/user_model.py"
|
||||||
|
],
|
||||||
|
"language": "python",
|
||||||
|
"reason": "obligations_function_local",
|
||||||
|
"stage": "obligations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "budget_exhausted",
|
||||||
|
"regions_unasked": 17,
|
||||||
|
"stage": "model"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "hunter_model_unavailable",
|
||||||
|
"stage": "map"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"delta": {
|
||||||
|
"cost_usd": "0.0067 -> 0.0067",
|
||||||
|
"entailed_findings": "7 -> 8",
|
||||||
|
"known_found_by_the_model_layer": "0 of 2 before task 2.2's fixes; 1 of 2 in the committed 2.2 run (the BOLA, once the budget stopped halting free arbitration); 2 of 2 now",
|
||||||
|
"peak_rss_mb": "868.6 -> 827.3",
|
||||||
|
"query_seconds": "40.86 -> 37.44",
|
||||||
|
"scan_seconds": "177.09 -> 182.2",
|
||||||
|
"vampi_sqli": "missed -> model_entailed at models/user_model.py:73:get_user"
|
||||||
|
},
|
||||||
|
"exit_code": 0,
|
||||||
|
"findings_by_rung": {
|
||||||
|
"execution_confirmed": 0,
|
||||||
|
"model_corroborated": 0,
|
||||||
|
"model_entailed": 8,
|
||||||
|
"suspicion": 54
|
||||||
|
},
|
||||||
|
"findings_total": 62,
|
||||||
|
"generated": "2026-09-09",
|
||||||
|
"how": {
|
||||||
|
"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 an entry point means. The driver now turns it on for regions the mapper found and named, and leaves it off for files the walker fell back to and for named functions that are not entry points.",
|
||||||
|
"labeled_methods_are_resolved_by_file_too": "cpg.method.nameExact(f) matches a same-named method in any module. A corpus of one-file excerpts could never surface that; a repository can.",
|
||||||
|
"sinks_may_sit_below_the_handler": "reachableByFlows already followed calls; the QUESTION did not. Sinks were filtered to the labeled function's own body, so a flow into another module had no sink to end at and the engine was silent. taint.sc now admits sinks in methods reachable from the labeled method, bounded at ENTRY_POINT_CALL_DEPTH = 3 -- handler to service to data access. Unbounded would make the question about most of the repository rather than about this entry point.",
|
||||||
|
"the_finding_names_the_sink_not_the_asker": "A crossing flow ends in a different file AND a different function, so all three parts of the site come from the sink. Reporting models/user_model.py:73:get_by_username would name a place that does not exist: line 73 is inside get_user."
|
||||||
|
},
|
||||||
|
"known_outcomes": [
|
||||||
|
{
|
||||||
|
"family": "injection",
|
||||||
|
"finding_id": "model:injection:models/user_model.py:73:get_user",
|
||||||
|
"id": "VAmPI-SQLI",
|
||||||
|
"line": 73,
|
||||||
|
"location": "models/user_model.py:get_user:73",
|
||||||
|
"matches": 3,
|
||||||
|
"result": "found",
|
||||||
|
"rung": "model_entailed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "access_control",
|
||||||
|
"finding_id": "model:access_control:api_views/users.py:?:update_password",
|
||||||
|
"id": "VAmPI-BOLA",
|
||||||
|
"line": null,
|
||||||
|
"location": "api_views/users.py:update_password:187",
|
||||||
|
"matches": 5,
|
||||||
|
"result": "found",
|
||||||
|
"rung": "model_entailed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mode": "standard",
|
||||||
|
"model": {
|
||||||
|
"arbitrate_seconds": 121.88,
|
||||||
|
"build_seconds": 6.08,
|
||||||
|
"by_rung": {
|
||||||
|
"execution_confirmed": 0,
|
||||||
|
"model_corroborated": 0,
|
||||||
|
"model_entailed": 8,
|
||||||
|
"suspicion": 38
|
||||||
|
},
|
||||||
|
"cost_usd": 0.0067,
|
||||||
|
"model_calls": 105,
|
||||||
|
"query_seconds": 37.44,
|
||||||
|
"regions_scanned": 22,
|
||||||
|
"regions_unasked": 17,
|
||||||
|
"regions_unjudged": 0,
|
||||||
|
"seconds": 165.4,
|
||||||
|
"unjudged_sample": []
|
||||||
|
},
|
||||||
|
"peak_rss_mb": 827.3,
|
||||||
|
"reading": "The cross-function region closes 2.2's no-go. VAmPI-SQLI is now entailed at models/user_model.py:73:get_user -- the exact execute call -- where a per-function region saw neither end of it. Both known vulnerabilities are now reported by the model layer at model_entailed, where before 2.2 neither was. The cost of asking the bigger question was nothing measurable: query time 40.9s -> 37.4s and peak memory 869MB -> 827MB, because scoping by file and following the call graph pull in opposite directions and roughly cancel at this size. That number is from ONE 520-line repository; the envelope checkout will say whether it holds.",
|
||||||
|
"repo": {
|
||||||
|
"commit": "f16052dce83f05847133ec98f01c5193a41de7d8",
|
||||||
|
"known": [
|
||||||
|
{
|
||||||
|
"family": "injection",
|
||||||
|
"id": "VAmPI-SQLI",
|
||||||
|
"in_scope": true,
|
||||||
|
"location": "models/user_model.py:get_user:73",
|
||||||
|
"weakness": "SQL injection: the username is interpolated into the query string at line 72 and executed at 73"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "access_control",
|
||||||
|
"id": "VAmPI-BOLA",
|
||||||
|
"in_scope": true,
|
||||||
|
"location": "api_views/users.py:update_password:187",
|
||||||
|
"weakness": "unauthorized password change: the path username is used instead of the authenticated token subject"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"language": "python",
|
||||||
|
"license": "MIT",
|
||||||
|
"measures": [
|
||||||
|
"detection"
|
||||||
|
],
|
||||||
|
"name": "vampi",
|
||||||
|
"url": "https://github.com/erev0s/VAmPI.git"
|
||||||
|
},
|
||||||
|
"scan_seconds": 182.2,
|
||||||
|
"spec": "contributor-scan 2.4 (Req 4.4, 8.1)",
|
||||||
|
"stages": {
|
||||||
|
"completed": [
|
||||||
|
"static",
|
||||||
|
"map"
|
||||||
|
],
|
||||||
|
"requested": [
|
||||||
|
"static",
|
||||||
|
"map"
|
||||||
|
],
|
||||||
|
"skipped": []
|
||||||
|
},
|
||||||
|
"still_open": [
|
||||||
|
"2.5 module-level regions: app.py's host='0.0.0.0' sits in , whose method Joern names <module>, and is still reported zero times.",
|
||||||
|
"2.6 access control entails 7 of 9 handlers, including register_user and get_all_users, which are meant to be public. Unchanged by this task and still the largest precision problem.",
|
||||||
|
"2.7 dominance witnesses carry no line, so access-control sites still read api_views/users.py:?:update_password."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
// cpgFile, sources, sinks, sanitizers (optional), function (optional: restrict to this method AND any
|
// cpgFile, sources, sinks, sanitizers (optional), function (optional: restrict to this method AND any
|
||||||
// closure lexically nested inside it -- see `nestedInLabeled`),
|
// closure lexically nested inside it -- see `nestedInLabeled`),
|
||||||
// parameterSources ("true" to treat the labeled function's parameters as untrusted -- see below),
|
// 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
|
// `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
|
// function="" and matches every sink in the repository, and the driver attributes that whole answer to
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
sanitizers: String = "",
|
sanitizers: String = "",
|
||||||
function: String = "",
|
function: String = "",
|
||||||
file: String = "",
|
file: String = "",
|
||||||
|
callDepth: String = "0",
|
||||||
parameterSources: String = "false",
|
parameterSources: String = "false",
|
||||||
requests: String = ""
|
requests: String = ""
|
||||||
) = {
|
) = {
|
||||||
@@ -50,7 +52,7 @@
|
|||||||
|
|
||||||
def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList
|
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
|
// 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
|
// 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 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
|
// 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.
|
// 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 =
|
def nestedInLabeled(m: io.shiftleft.codepropertygraph.generated.nodes.Method): Boolean =
|
||||||
labeledMethods.exists { lm =>
|
labeledMethods.exists { lm =>
|
||||||
@@ -111,8 +142,17 @@
|
|||||||
val all = cpg.call.filter(c =>
|
val all = cpg.call.filter(c =>
|
||||||
sinkNames.exists(n => c.name == n || c.code.startsWith(n + "(") || c.code.startsWith(n) || c.methodFullName.contains(n))
|
sinkNames.exists(n => c.name == n || c.code.startsWith(n + "(") || c.code.startsWith(n) || c.methodFullName.contains(n))
|
||||||
)
|
)
|
||||||
|
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))
|
val inFile = if (fileS.isEmpty) all else all.filter(_.method.filename.endsWith(fileS))
|
||||||
if (function.isEmpty) inFile else inFile.filter(c => nestedInLabeled(c.method))
|
inFile.filter(c => nestedInLabeled(c.method))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val rows = sinkCalls.l.flatMap { sink =>
|
val rows = sinkCalls.l.flatMap { sink =>
|
||||||
@@ -137,12 +177,15 @@
|
|||||||
"sink" -> sink.code.take(200),
|
"sink" -> sink.code.take(200),
|
||||||
"sinkLine" -> sink.lineNumber.getOrElse(-1).toString,
|
"sinkLine" -> sink.lineNumber.getOrElse(-1).toString,
|
||||||
"sinkMethod" -> sink.method.name,
|
"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),
|
"source" -> elements.headOption.getOrElse("").take(200),
|
||||||
"sanitized" -> sanitized,
|
"sanitized" -> sanitized,
|
||||||
"length" -> elements.size,
|
"length" -> elements.size,
|
||||||
"sinkArity" -> arity,
|
"sinkArity" -> arity,
|
||||||
"sinkArg0Literal" -> arg0Literal,
|
"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) =>
|
val answers = parsed.map { case (id, req) =>
|
||||||
def field(name: String): String = req.obj.get(name).map(_.str).getOrElse("")
|
def field(name: String): String = req.obj.get(name).map(_.str).getOrElse("")
|
||||||
val paramSrc = req.obj.get("parameterSources").map(_.str).getOrElse("false")
|
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)))
|
println(ujson.write(ujson.Obj.from(answers)))
|
||||||
} else {
|
} 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---")
|
println("---OUSAST-CPG-END---")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ class Verdict:
|
|||||||
family: str
|
family: str
|
||||||
witness: str = "" # the taint path or the dominance fact; empty only at `suspicion`
|
witness: str = "" # the taint path or the dominance fact; empty only at `suspicion`
|
||||||
contradiction: str = "" # why an LLM claim was dropped, when it was
|
contradiction: str = "" # why an LLM claim was dropped, when it was
|
||||||
|
# ``path:line`` of the evidence, when the arbiter knows it. Once a flow may cross into another module,
|
||||||
|
# the region that asked the question is no longer where the answer lives, and a finding reported against
|
||||||
|
# the handler's file when the bug is two modules away is unactionable.
|
||||||
|
location: str = ""
|
||||||
|
|
||||||
|
|
||||||
def at_rung(finding: _Finding, verdict: Verdict | None) -> _Finding:
|
def at_rung(finding: _Finding, verdict: Verdict | None) -> _Finding:
|
||||||
|
|||||||
@@ -66,7 +66,13 @@ class ModelFinding:
|
|||||||
|
|
||||||
|
|
||||||
def _arbitrate(
|
def _arbitrate(
|
||||||
cpg: CpgResult, spec: TaintSpec | DominanceSpec | ConfigSpec, *, path: str, function: str, parameter_sources: bool
|
cpg: CpgResult,
|
||||||
|
spec: TaintSpec | DominanceSpec | ConfigSpec,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
function: str,
|
||||||
|
parameter_sources: bool,
|
||||||
|
call_depth: int,
|
||||||
) -> Verdict | None:
|
) -> Verdict | None:
|
||||||
"""Route a family to the arbiter that can actually decide it.
|
"""Route a family to the arbiter that can actually decide it.
|
||||||
|
|
||||||
@@ -79,7 +85,7 @@ def _arbitrate(
|
|||||||
return dominance_verdict(cpg, spec, function=function)
|
return dominance_verdict(cpg, spec, function=function)
|
||||||
if isinstance(spec, ConfigSpec):
|
if isinstance(spec, ConfigSpec):
|
||||||
return config_verdict(cpg, spec, function=function, file=path)
|
return config_verdict(cpg, spec, function=function, file=path)
|
||||||
return taint_verdict(cpg, spec, function=function, file=path, parameter_sources=parameter_sources)
|
return taint_verdict(cpg, spec, function=function, file=path, parameter_sources=parameter_sources, call_depth=call_depth)
|
||||||
|
|
||||||
|
|
||||||
def scan_region(
|
def scan_region(
|
||||||
@@ -92,13 +98,14 @@ def scan_region(
|
|||||||
model: str = "",
|
model: str = "",
|
||||||
candidates: Sequence[Mapping[str, object]] = (),
|
candidates: Sequence[Mapping[str, object]] = (),
|
||||||
parameter_sources: bool = False,
|
parameter_sources: bool = False,
|
||||||
|
call_depth: int = 0,
|
||||||
) -> list[ModelFinding]:
|
) -> list[ModelFinding]:
|
||||||
"""Findings for one region and family, ordered strongest first."""
|
"""Findings for one region and family, ordered strongest first."""
|
||||||
answer = _arbitrate(cpg, spec, path=path, function=function, parameter_sources=parameter_sources)
|
answer = _arbitrate(cpg, spec, path=path, function=function, parameter_sources=parameter_sources, call_depth=call_depth)
|
||||||
|
|
||||||
if answer is not None and answer.rung is Rung.ENTAILED:
|
if answer is not None and answer.rung is Rung.ENTAILED:
|
||||||
# The graph decided. No model call, and no candidate needs asking about: this region has its finding.
|
# The graph decided. No model call, and no candidate needs asking about: this region has its finding.
|
||||||
return [ModelFinding(site=_site(path, function, answer.witness), family=spec.family, rung=Rung.ENTAILED, witness=answer.witness)]
|
return [ModelFinding(site=_site(path, function, answer), family=spec.family, rung=Rung.ENTAILED, witness=answer.witness)]
|
||||||
|
|
||||||
if answer is not None:
|
if answer is not None:
|
||||||
# A real flow through a sanitizer. Whether it suffices is the residual, and the model HAS coverage
|
# A real flow through a sanitizer. Whether it suffices is the residual, and the model HAS coverage
|
||||||
@@ -107,9 +114,7 @@ def scan_region(
|
|||||||
return []
|
return []
|
||||||
said = _ask(client, model, residual_question(spec, answer.witness, function))
|
said = _ask(client, model, residual_question(spec, answer.witness, function))
|
||||||
if said:
|
if said:
|
||||||
return [
|
return [ModelFinding(site=_site(path, function, answer), family=spec.family, rung=Rung.CORROBORATED, witness=answer.witness)]
|
||||||
ModelFinding(site=_site(path, function, answer.witness), family=spec.family, rung=Rung.CORROBORATED, witness=answer.witness)
|
|
||||||
]
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# The model resolved nothing. It is SILENT, not negative — so the enumerator's candidates are put to the
|
# The model resolved nothing. It is SILENT, not negative — so the enumerator's candidates are put to the
|
||||||
@@ -203,13 +208,21 @@ def _ask(client: Any, model: str, question: str) -> bool | None:
|
|||||||
_LINE_IN_WITNESS = re.compile(r"\(line (\d+)")
|
_LINE_IN_WITNESS = re.compile(r"\(line (\d+)")
|
||||||
|
|
||||||
|
|
||||||
def _site(path: str, function: str, witness: str) -> str:
|
def _site(path: str, function: str, verdict: Verdict) -> str:
|
||||||
"""``path:line:function`` -- the same shape a candidate id carries, because one consumer parses both.
|
"""``path:line:function`` -- the same shape a candidate id carries, because one consumer parses both.
|
||||||
|
|
||||||
An arbitrated finding used to be ``function:line``, which the report layer then read as ``path:line``:
|
An arbitrated finding used to be ``function:line``, which the report layer then read as ``path:line``:
|
||||||
every entailed finding arrived with a function name where its file belonged and no line at all. A
|
every entailed finding arrived with a function name where its file belonged and no line at all. A
|
||||||
contributor cannot open that, and nothing downstream can match it against a known location.
|
contributor cannot open that, and nothing downstream can match it against a known location.
|
||||||
|
|
||||||
|
The arbiter's own ``location`` wins when it has one. Once a flow may cross into another module, the
|
||||||
|
region that asked is no longer where the answer lives, and naming the asker would send a contributor to
|
||||||
|
the wrong file.
|
||||||
"""
|
"""
|
||||||
|
if verdict.location:
|
||||||
|
# Already `path:line:function`, and every part of it is the sink's. See `taint._location`.
|
||||||
|
return verdict.location
|
||||||
|
witness = verdict.witness
|
||||||
# A regex, because the witness is prose and the delimiter after the number varies: "(line 17,"
|
# A regex, because the witness is prose and the delimiter after the number varies: "(line 17,"
|
||||||
# in one family and "(line 17):" in another. Splitting on a comma produced sites like
|
# in one family and "(line 17):" in another. Splitting on a comma produced sites like
|
||||||
# `config.py:17): permissive literal '0.0.0.0':?`.
|
# `config.py:17): permissive literal '0.0.0.0':?`.
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ from .taxonomy import load_families
|
|||||||
# would let a fourth spec kind reach `_grouped` and silently fall through to the taint branch.
|
# would let a fourth spec kind reach `_grouped` and silently fall through to the taint branch.
|
||||||
ArbiterSpec = TaintSpec | DominanceSpec | ConfigSpec
|
ArbiterSpec = TaintSpec | DominanceSpec | ConfigSpec
|
||||||
|
|
||||||
|
# How far below an entry point a sink may sit and still be attributed to it.
|
||||||
|
#
|
||||||
|
# An entry point is the trust boundary, so its parameters ARE attacker input and a sink it reaches is its
|
||||||
|
# responsibility -- which is what makes VAmPI's SQL injection askable at all: a request parameter reaches
|
||||||
|
# `get_by_username`, is passed to `User.get_user` in another module, interpolated there and executed. A
|
||||||
|
# function-scoped question sees neither end of that.
|
||||||
|
#
|
||||||
|
# Bounded, and deliberately shallow. "Every method transitively reachable from a handler" is most of a
|
||||||
|
# repository: the question stops being about this entry point and the query stops being affordable. Three
|
||||||
|
# levels covers handler -> service -> data access, which is where these bugs live. Raising it is a
|
||||||
|
# measurement, not a preference.
|
||||||
|
ENTRY_POINT_CALL_DEPTH = 3
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_RUNG_ORDER = {Rung.ENTAILED: 0, Rung.CORROBORATED: 1, Rung.SUSPICION: 2, Rung.EXECUTION_CONFIRMED: -1}
|
_RUNG_ORDER = {Rung.ENTAILED: 0, Rung.CORROBORATED: 1, Rung.SUSPICION: 2, Rung.EXECUTION_CONFIRMED: -1}
|
||||||
@@ -167,7 +180,11 @@ def scan_repository(
|
|||||||
client=None if exhausted else counted,
|
client=None if exhausted else counted,
|
||||||
model=model,
|
model=model,
|
||||||
candidates=candidates,
|
candidates=candidates,
|
||||||
parameter_sources=False, # a repository's parameters are not all attacker input
|
# A repository's parameters are not all attacker input -- but an ENTRY POINT's are, by
|
||||||
|
# definition. That is the trust boundary, and it is the one place both halves of the old
|
||||||
|
# objection stop applying.
|
||||||
|
parameter_sources=_is_entry_point(region),
|
||||||
|
call_depth=ENTRY_POINT_CALL_DEPTH if _is_entry_point(region) else 0,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 -- one bad region must not end the scan
|
except Exception as exc: # noqa: BLE001 -- one bad region must not end the scan
|
||||||
logger.warning("model scan failed for %s: %s", region.path, exc)
|
logger.warning("model scan failed for %s: %s", region.path, exc)
|
||||||
@@ -218,16 +235,36 @@ def _grouped(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, d
|
|||||||
grouped: dict[str, dict[str, Mapping[str, object]]] = {}
|
grouped: dict[str, dict[str, Mapping[str, object]]] = {}
|
||||||
for rid, region, spec in work:
|
for rid, region, spec in work:
|
||||||
function = region.function or ""
|
function = region.function or ""
|
||||||
|
entry = _is_entry_point(region)
|
||||||
if isinstance(spec, DominanceSpec):
|
if isinstance(spec, DominanceSpec):
|
||||||
kind, params = "dominance", dominance_params(spec, function=function)
|
kind, params = "dominance", dominance_params(spec, function=function)
|
||||||
elif isinstance(spec, ConfigSpec):
|
elif isinstance(spec, ConfigSpec):
|
||||||
kind, params = "config", config_params(spec, function=function, file=region.path)
|
kind, params = "config", config_params(spec, function=function, file=region.path)
|
||||||
else:
|
else:
|
||||||
kind, params = "taint", taint_params(spec, function=function, file=region.path, parameter_sources=False)
|
kind, params = (
|
||||||
|
"taint",
|
||||||
|
taint_params(
|
||||||
|
spec,
|
||||||
|
function=function,
|
||||||
|
file=region.path,
|
||||||
|
parameter_sources=entry,
|
||||||
|
call_depth=ENTRY_POINT_CALL_DEPTH if entry else 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
grouped.setdefault(kind, {})[rid] = params
|
grouped.setdefault(kind, {})[rid] = params
|
||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def _is_entry_point(region: ScanRegion) -> bool:
|
||||||
|
"""Is this region a place untrusted input actually enters?
|
||||||
|
|
||||||
|
A named function the entry-point mapper found, not a file the walker fell back to. The distinction is
|
||||||
|
what makes treating parameters as untrusted sound: a handler's parameters carry request data, an
|
||||||
|
arbitrary helper's carry whatever its caller had.
|
||||||
|
"""
|
||||||
|
return region.source == "entry_point" and bool(region.function)
|
||||||
|
|
||||||
|
|
||||||
def _spec_for(family: str, language: str) -> ArbiterSpec | None:
|
def _spec_for(family: str, language: str) -> ArbiterSpec | None:
|
||||||
"""The spec whose arbiter can decide this family. Routing stays a lookup; dispatch stays in `_arbitrate`."""
|
"""The spec whose arbiter can decide this family. Routing stays a lookup; dispatch stays in `_arbitrate`."""
|
||||||
if family == "access_control":
|
if family == "access_control":
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ from .ladder import Rung, Verdict
|
|||||||
from .specs import TaintSpec
|
from .specs import TaintSpec
|
||||||
|
|
||||||
|
|
||||||
def request_params(spec: TaintSpec, *, function: str = "", file: str = "", parameter_sources: bool = False) -> dict[str, object]:
|
def request_params(
|
||||||
|
spec: TaintSpec, *, function: str = "", file: str = "", parameter_sources: bool = False, call_depth: int = 0
|
||||||
|
) -> dict[str, object]:
|
||||||
"""The query parameters this arbiter sends. Shared with the batcher so there is one definition, not two.
|
"""The query parameters this arbiter sends. Shared with the batcher so there is one definition, not two.
|
||||||
|
|
||||||
Two copies of a parameter dict is precisely the duplication that produced five bugs of one shape in the
|
Two copies of a parameter dict is precisely the duplication that produced five bugs of one shape in the
|
||||||
@@ -37,17 +39,29 @@ def request_params(spec: TaintSpec, *, function: str = "", file: str = "", param
|
|||||||
"function": function,
|
"function": function,
|
||||||
"file": file,
|
"file": file,
|
||||||
"parameterSources": "true" if parameter_sources else "false",
|
"parameterSources": "true" if parameter_sources else "false",
|
||||||
|
"callDepth": str(call_depth),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def verdict(cpg: CpgResult, spec: TaintSpec, *, function: str = "", file: str = "", parameter_sources: bool = False) -> Verdict | None:
|
def verdict(
|
||||||
|
cpg: CpgResult, spec: TaintSpec, *, function: str = "", file: str = "", parameter_sources: bool = False, call_depth: int = 0
|
||||||
|
) -> Verdict | None:
|
||||||
"""The strongest verdict the taint query supports for ``spec`` in ``function``, or ``None`` to stay at suspicion.
|
"""The strongest verdict the taint query supports for ``spec`` in ``function``, or ``None`` to stay at suspicion.
|
||||||
|
|
||||||
``parameter_sources`` additionally treats the labeled function's own parameters as untrusted. That is right
|
``parameter_sources`` additionally treats the labeled function's own parameters as untrusted. That is right
|
||||||
for a function-level pair, where the function boundary *is* the trust boundary, and wrong for a whole
|
for a function-level pair, where the function boundary *is* the trust boundary, and wrong for a whole
|
||||||
repository, where most parameters carry internal values — so it is off by default and the caller opts in.
|
repository, where most parameters carry internal values — so it is off by default and the caller opts in.
|
||||||
|
An ENTRY POINT is the case where both are true at once: its parameters are attacker input by definition,
|
||||||
|
which is what the driver uses to decide.
|
||||||
|
|
||||||
|
``call_depth`` admits sinks that many call-graph levels below the labeled method. Zero keeps the old
|
||||||
|
function-local question; above zero is what lets a handler's parameter reach a sink in another module,
|
||||||
|
which is the shape VAmPI's SQL injection has and the shape a function-scoped question cannot see.
|
||||||
"""
|
"""
|
||||||
rows = cpg.run("taint", request_params(spec, function=function, file=file, parameter_sources=parameter_sources))
|
rows = cpg.run(
|
||||||
|
"taint",
|
||||||
|
request_params(spec, function=function, file=file, parameter_sources=parameter_sources, call_depth=call_depth),
|
||||||
|
)
|
||||||
flows = _flows(rows, function=function)
|
flows = _flows(rows, function=function)
|
||||||
# A sink whose *shape* is safe is the fix, not the bug. `execute(sql, params)` binds rather than
|
# A sink whose *shape* is safe is the fix, not the bug. `execute(sql, params)` binds rather than
|
||||||
# interpolates and `printf("literal", x)` has a constant format string, so the flow that reaches them is
|
# interpolates and `printf("literal", x)` has a constant format string, so the flow that reaches them is
|
||||||
@@ -66,7 +80,7 @@ def verdict(cpg: CpgResult, spec: TaintSpec, *, function: str = "", file: str =
|
|||||||
# Deterministic by construction: a total order, then take the first. Nothing depends on engine ordering.
|
# Deterministic by construction: a total order, then take the first. Nothing depends on engine ordering.
|
||||||
chosen = min(unsanitized or considered, key=lambda flow: _rank(flow, spec))
|
chosen = min(unsanitized or considered, key=lambda flow: _rank(flow, spec))
|
||||||
rung = Rung.ENTAILED if not chosen["sanitized"] else Rung.CORROBORATED
|
rung = Rung.ENTAILED if not chosen["sanitized"] else Rung.CORROBORATED
|
||||||
return Verdict(rung=rung, family=spec.family, witness=_witness(chosen))
|
return Verdict(rung=rung, family=spec.family, witness=_witness(chosen), location=_location(chosen))
|
||||||
|
|
||||||
|
|
||||||
def _flows(rows: object, *, function: str) -> list[dict[str, object]]:
|
def _flows(rows: object, *, function: str) -> list[dict[str, object]]:
|
||||||
@@ -87,6 +101,7 @@ def _flows(rows: object, *, function: str) -> list[dict[str, object]]:
|
|||||||
"inLabeledScope": row.get("inLabeledScope"),
|
"inLabeledScope": row.get("inLabeledScope"),
|
||||||
"sinkLine": str(row.get("sinkLine", "")),
|
"sinkLine": str(row.get("sinkLine", "")),
|
||||||
"sinkMethod": str(row.get("sinkMethod", "")),
|
"sinkMethod": str(row.get("sinkMethod", "")),
|
||||||
|
"sinkFile": str(row.get("sinkFile", "")),
|
||||||
"source": str(row.get("source", "")),
|
"source": str(row.get("source", "")),
|
||||||
"sanitized": bool(row.get("sanitized", False)),
|
"sanitized": bool(row.get("sanitized", False)),
|
||||||
"length": _as_int(row.get("length")),
|
"length": _as_int(row.get("length")),
|
||||||
@@ -155,7 +170,24 @@ def _rank(flow: Mapping[str, object], spec: TaintSpec) -> tuple[int, int, str, s
|
|||||||
|
|
||||||
def _witness(flow: Mapping[str, object]) -> str:
|
def _witness(flow: Mapping[str, object]) -> str:
|
||||||
line = flow.get("sinkLine") or "?"
|
line = flow.get("sinkLine") or "?"
|
||||||
return f"{flow['source']} -> {flow['sink']} (line {line}, {flow['length']} steps)"
|
where = str(flow.get("sinkFile") or "")
|
||||||
|
at = f" in {where}" if where else ""
|
||||||
|
return f"{flow['source']} -> {flow['sink']}{at} (line {line}, {flow['length']} steps)"
|
||||||
|
|
||||||
|
|
||||||
|
def _location(flow: Mapping[str, object]) -> str:
|
||||||
|
"""``path:line:function`` of the SINK, when the engine reported a file. Empty leaves the region's own.
|
||||||
|
|
||||||
|
All three parts come from the sink, not from the region that asked. A flow that crosses modules ends in
|
||||||
|
a different file AND a different function, and naming the entry point beside the sink's line describes a
|
||||||
|
place that does not exist -- `models/user_model.py:73:get_by_username`, where line 73 is inside
|
||||||
|
`get_user`.
|
||||||
|
"""
|
||||||
|
where = str(flow.get("sinkFile") or "")
|
||||||
|
line = str(flow.get("sinkLine") or "")
|
||||||
|
if not where or not line.lstrip("-").isdigit() or int(line) < 0:
|
||||||
|
return ""
|
||||||
|
return f"{where}:{line}:{flow.get('sinkMethod') or '?'}"
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["request_params", "verdict"]
|
__all__ = ["request_params", "verdict"]
|
||||||
|
|||||||
@@ -268,8 +268,32 @@ def test_a_witness_line_is_read_whatever_follows_the_number() -> None:
|
|||||||
Splitting on the comma produced sites like `config.py:17): permissive literal '0.0.0.0':?` -- a line
|
Splitting on the comma produced sites like `config.py:17): permissive literal '0.0.0.0':?` -- a line
|
||||||
number no editor can open and no consumer can compare.
|
number no editor can open and no consumer can compare.
|
||||||
"""
|
"""
|
||||||
|
from openultrasast.model.ladder import Rung, Verdict
|
||||||
from openultrasast.model.pipeline import _site
|
from openultrasast.model.pipeline import _site
|
||||||
|
|
||||||
assert _site("config.py", "", "permissive literal '0.0.0.0' (line 17): permissive") == "config.py:17:?"
|
def at(witness: str) -> Verdict:
|
||||||
assert _site("a.py", "run", "flow reaches os.system (line 9, via x)") == "a.py:9:run"
|
return Verdict(rung=Rung.ENTAILED, family="injection", witness=witness)
|
||||||
assert _site("a.py", "run", "no line at all") == "a.py:?:run"
|
|
||||||
|
assert _site("config.py", "", at("permissive literal '0.0.0.0' (line 17): permissive")) == "config.py:17:?"
|
||||||
|
assert _site("a.py", "run", at("flow reaches os.system (line 9, via x)")) == "a.py:9:run"
|
||||||
|
assert _site("a.py", "run", at("no line at all")) == "a.py:?:run"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_arbiter_that_knows_where_the_evidence_is_names_that_file() -> None:
|
||||||
|
"""Once a flow may cross modules, the region that asked is not where the answer lives.
|
||||||
|
|
||||||
|
VAmPI's SQL injection is reached from a handler in api_views/users.py and executed in
|
||||||
|
models/user_model.py. Reporting it against the handler sends a contributor to the wrong file.
|
||||||
|
"""
|
||||||
|
from openultrasast.model.ladder import Rung, Verdict
|
||||||
|
from openultrasast.model.pipeline import _site
|
||||||
|
|
||||||
|
crossed = Verdict(
|
||||||
|
rung=Rung.ENTAILED,
|
||||||
|
family="injection",
|
||||||
|
witness="username -> execute",
|
||||||
|
location="models/user_model.py:73:get_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The asking region is api_views/users.py:get_by_username; every part of the site is the sink's instead.
|
||||||
|
assert _site("api_views/users.py", "get_by_username", crossed) == "models/user_model.py:73:get_user"
|
||||||
|
|||||||
@@ -274,3 +274,38 @@ def test_a_failed_build_still_reports_what_the_build_cost() -> None:
|
|||||||
|
|
||||||
assert result.build_seconds >= 0.0
|
assert result.build_seconds >= 0.0
|
||||||
assert result.query_seconds == 0.0, "nothing was queried"
|
assert result.query_seconds == 0.0, "nothing was queried"
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_an_entry_point_gets_the_interprocedural_question(tmp_path) -> None:
|
||||||
|
"""contributor-scan 2.4: the trust boundary decides, not convenience.
|
||||||
|
|
||||||
|
An entry point's parameters ARE attacker input and a sink it reaches is its responsibility, so its
|
||||||
|
question follows the call graph. A file the walker fell back to has neither property: treating an
|
||||||
|
arbitrary helper's parameters as untrusted is what the repository default was protecting against.
|
||||||
|
"""
|
||||||
|
from openultrasast.model.regions import ScanRegion
|
||||||
|
from openultrasast.model.scan import ENTRY_POINT_CALL_DEPTH, _grouped, _spec_for
|
||||||
|
|
||||||
|
def region(source: str, function: str | None) -> ScanRegion:
|
||||||
|
return ScanRegion(path="a.py", function=function, language="python", families=("injection",), rank=1.0, source=source)
|
||||||
|
|
||||||
|
spec = _spec_for("injection", "python")
|
||||||
|
assert spec is not None
|
||||||
|
handler = region("entry_point", "get_by_username")
|
||||||
|
fallback = region("file_fallback", None)
|
||||||
|
named_but_not_an_entry_point = region("file_fallback", "helper")
|
||||||
|
|
||||||
|
grouped = _grouped((("0", handler, spec), ("1", fallback, spec), ("2", named_but_not_an_entry_point, spec)))["taint"]
|
||||||
|
|
||||||
|
assert grouped["0"]["parameterSources"] == "true"
|
||||||
|
assert grouped["0"]["callDepth"] == str(ENTRY_POINT_CALL_DEPTH)
|
||||||
|
for rid in ("1", "2"):
|
||||||
|
assert grouped[rid]["parameterSources"] == "false"
|
||||||
|
assert grouped[rid]["callDepth"] == "0", "following calls from an arbitrary function is not sound"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_call_depth_is_bounded() -> None:
|
||||||
|
"""'Every method reachable from a handler' is most of a repository, and an unbounded question is slow."""
|
||||||
|
from openultrasast.model.scan import ENTRY_POINT_CALL_DEPTH
|
||||||
|
|
||||||
|
assert 0 < ENTRY_POINT_CALL_DEPTH <= 5
|
||||||
|
|||||||
Reference in New Issue
Block a user