Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JS: New Command Execution Sinks #14198

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions javascript/ql/lib/javascript.qll
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import semmle.javascript.frameworks.Request
import semmle.javascript.frameworks.RxJS
import semmle.javascript.frameworks.ServerLess
import semmle.javascript.frameworks.ShellJS
import semmle.javascript.frameworks.Execa
import semmle.javascript.frameworks.Snapdragon
import semmle.javascript.frameworks.SystemCommandExecutors
import semmle.javascript.frameworks.SQL
Expand Down
234 changes: 234 additions & 0 deletions javascript/ql/lib/semmle/javascript/frameworks/Execa.qll
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* Models the `execa` library in terms of `FileSystemAccess` and `SystemCommandExecution`.
*/

import javascript
import semmle.javascript.security.dataflow.RequestForgeryCustomizations
import semmle.javascript.security.dataflow.UrlConcatenation

/**
* Provide model for [Execa](https://github.com/sindresorhus/execa) package
*/
module Execa {
/**
* The Execa input file option
*/
class ExecaRead extends FileSystemReadAccess, DataFlow::Node {
API::Node execaNode;

ExecaRead() {
(
execaNode = API::moduleImport("execa").getMember("$").getParameter(0)
or
execaNode =
API::moduleImport("execa")
.getMember(["execa", "execaCommand", "execaCommandSync", "execaSync"])
.getParameter([0, 1, 2])
) and
this = execaNode.asSink()
}

// data is the output of a command so IDK how it can be implemented
override DataFlow::Node getADataNode() { none() }

override DataFlow::Node getAPathArgument() {
result = execaNode.getMember("inputFile").asSink()
}
}

/**
* A call to `execa.execa` or `execa.execaSync`
*/
class ExecaCall extends API::CallNode {
string name;

ExecaCall() {
this = API::moduleImport("execa").getMember("execa").getACall() and
name = "execa"
or
this = API::moduleImport("execa").getMember("execaSync").getACall() and
name = "execaSync"
}

/** Gets the name of the exported function, such as `rm` in `shelljs.rm()`. */
string getName() { result = name }
}

/**
* The system command execution nodes for `execa.execa` or `execa.execaSync` functions
*/
class ExecaExec extends SystemCommandExecution, ExecaCall {
ExecaExec() { name = ["execa", "execaSync"] }

override DataFlow::Node getACommandArgument() { result = this.getArgument(0) }

override predicate isShellInterpreted(DataFlow::Node arg) {
// if shell: true then first and second args are sinks
// options can be third argument
arg = [this.getArgument(0), this.getParameter(1).getUnknownMember().asSink()] and
isExecaShellEnable(this.getParameter(2))
or
// options can be second argument
arg = this.getArgument(0) and
isExecaShellEnable(this.getParameter(1))
}

override predicate isSync() { name = "execaSync" }

override DataFlow::Node getOptionsArg() {
result = this.getLastArgument() and result.asExpr() instanceof ObjectExpr
}
}

/**
* A call to `execa.$` or `execa.$.sync` tag functions
*/
private class ExecaScriptExpr extends DataFlow::ExprNode {
string name;

ExecaScriptExpr() {
this.asExpr() =
[
API::moduleImport("execa").getMember("$"),
API::moduleImport("execa").getMember("$").getReturn()
].getAValueReachableFromSource().asExpr() and
name = "ASync"
or
this.asExpr() =
[
API::moduleImport("execa").getMember("$").getMember("sync"),
API::moduleImport("execa").getMember("$").getMember("sync").getReturn()
].getAValueReachableFromSource().asExpr() and
name = "Sync"
}

/** Gets the name of the exported function, such as `rm` in `shelljs.rm()`. */
string getName() { result = name }
}

/**
* The system command execution nodes for `execa.$` or `execa.$.sync` tag functions
*/
class ExecaScriptEec extends SystemCommandExecution, ExecaScriptExpr {
ExecaScriptEec() { name = ["Sync", "ASync"] }

override DataFlow::Node getACommandArgument() {
result.asExpr() = templateLiteralChildAsSink(this.asExpr()).getChildExpr(0)
}

override predicate isShellInterpreted(DataFlow::Node arg) {
// $({shell: true})`${sink} ${sink} .. ${sink}`
// ISSUE: $`cmd args` I can't reach the tag function argument easily
exists(TemplateLiteral tmpL | templateLiteralChildAsSink(this.asExpr()) = tmpL |
arg.asExpr() = tmpL.getAChildExpr+() and
isExecaShellEnableWithExpr(this.asExpr().(CallExpr).getArgument(0))
)
}

override DataFlow::Node getArgumentList() {
// $`${Can Not Be sink} ${sink} .. ${sink}`
exists(TemplateLiteral tmpL | templateLiteralChildAsSink(this.asExpr()) = tmpL |
result.asExpr() = tmpL.getAChildExpr+() and
not result.asExpr() = tmpL.getChildExpr(0)
)
}

override predicate isSync() { name = "Sync" }

override DataFlow::Node getOptionsArg() {
result = this.asExpr().getAChildExpr*().flow() and result.asExpr() instanceof ObjectExpr
}
}

/**
* A call to `execa.execaCommandSync` or `execa.execaCommand`
*/
private class ExecaCommandCall extends API::CallNode {
string name;

ExecaCommandCall() {
this = API::moduleImport("execa").getMember("execaCommandSync").getACall() and
name = "execaCommandSync"
or
this = API::moduleImport("execa").getMember("execaCommand").getACall() and
name = "execaCommand"
}

/** Gets the name of the exported function, such as `rm` in `shelljs.rm()`. */
string getName() { result = name }
}

/**
* The system command execution nodes for `execa.execaCommand` or `execa.execaCommandSync` functions
*/
class ExecaCommandExec2 extends SystemCommandExecution, DataFlow::CallNode {
ExecaCommandExec2() { this = API::moduleImport("execa").getMember("execaCommand").getACall() }

override DataFlow::Node getACommandArgument() { result = this.getArgument(0) }

override DataFlow::Node getArgumentList() { result = this.getArgument(0) }

override predicate isShellInterpreted(DataFlow::Node arg) { arg = this.getArgument(0) }

override predicate isSync() { none() }

override DataFlow::Node getOptionsArg() { result = this }
}

/**
* The system command execution nodes for `execa.execaCommand` or `execa.execaCommandSync` functions
*/
class ExecaCommandExec extends SystemCommandExecution, ExecaCommandCall {
ExecaCommandExec() { name = ["execaCommand", "execaCommandSync"] }

override DataFlow::Node getACommandArgument() {
result = this.(DataFlow::CallNode).getArgument(0)
}

override DataFlow::Node getArgumentList() {
// execaCommand("echo " + sink);
// execaCommand(`echo ${sink}`);
result.asExpr() = this.getParameter(0).asSink().asExpr().getAChildExpr+() and
not result.asExpr() = this.getArgument(0).asExpr().getChildExpr(0)
}

override predicate isShellInterpreted(DataFlow::Node arg) {
// execaCommandSync(sink1 + sink2, {shell: true})
arg.asExpr() = this.getArgument(0).asExpr().getAChildExpr+() and
isExecaShellEnable(this.getParameter(1))
or
// there is only one argument that is constructed in previous nodes,
// it makes sanitizing really hard to select whether it is vulnerable to argument injection or not
arg = this.getParameter(0).asSink() and
not exists(this.getArgument(0).asExpr().getChildExpr(1))
}

override predicate isSync() { name = "execaCommandSync" }

override DataFlow::Node getOptionsArg() {
result = this.getLastArgument() and result.asExpr() instanceof ObjectExpr
}
}

// Holds if left parameter is the left child of a template literal and returns the template literal
private TemplateLiteral templateLiteralChildAsSink(Expr left) {
exists(TaggedTemplateExpr parent |
parent.getTemplate() = result and
left = parent.getChildExpr(0)
)
}

// Holds whether Execa has shell enabled options or not, get Parameter responsible for options
private predicate isExecaShellEnable(API::Node n) {
n.getMember("shell").asSink().asExpr().(BooleanLiteral).getValue() = "true"
}

// Holds whether Execa has shell enabled options or not, get Parameter responsible for options
private predicate isExecaShellEnableWithExpr(Expr n) {
exists(ObjectExpr o, Property p | o = n.getAChildExpr*() |
o.getAChild() = p and
p.getAChild().(Label).getName() = "shell" and
p.getAChild().(Literal).getValue() = "true"
)
}
}
15 changes: 15 additions & 0 deletions javascript/ql/lib/semmle/javascript/frameworks/NodeJSLib.qll
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,21 @@ module NodeJSLib {
}
}

/**
* The dynamic import expression input can be a `data:` URL which loads any module from that data
*/
class DynamicImport extends SystemCommandExecution, DataFlow::ExprNode {
DynamicImport() { this = any(DynamicImportExpr e).getAChildExpr().flow() }

override DataFlow::Node getACommandArgument() { result = this }

override predicate isShellInterpreted(DataFlow::Node arg) { arg = this }

override predicate isSync() { none() }

override DataFlow::Node getOptionsArg() { none() }
}

/**
* A call to a method from module `child_process`.
*/
Expand Down
21 changes: 19 additions & 2 deletions javascript/ql/lib/semmle/javascript/frameworks/ShellJS.qll
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
/**
* Models the `shelljs` library in terms of `FileSystemAccess` and `SystemCommandExecution`.
*
* https://www.npmjs.com/package/shelljs
*/

import javascript

module ShellJS {
API::Node shellJSMember() {
result = API::moduleImport("shelljs")
or
result =
shellJSMember()
.getMember([
"exec", "cd", "cp", "touch", "chmod", "pushd", "find", "ls", "ln", "mkdir", "mv",
"rm", "cat", "head", "sort", "tail", "uniq", "grep", "sed", "to", "toEnd", "echo"
])
.getReturn()
}

/**
* Gets an import of the `shelljs` or `async-shelljs` module.
*/
DataFlow::SourceNode shelljs() {
result = DataFlow::moduleImport("shelljs") or
result = shellJSMember().asSource() or
result = DataFlow::moduleImport("async-shelljs")
}

Expand Down Expand Up @@ -39,7 +53,10 @@ module ShellJS {

/** The `shelljs.exec` library modeled as a `shelljs` member. */
private class ShellJsExec extends Range {
ShellJsExec() { this = DataFlow::moduleImport("shelljs.exec") }
ShellJsExec() {
this = DataFlow::moduleImport("shelljs.exec") or
this = shellJSMember().getMember("exec").asSource()
}

override string getName() { result = "exec" }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
test_FileSystemAccess
| tst.js:18:9:18:23 | { shell: true } |
| tst.js:20:9:20:24 | { shell: false } |
| tst.js:24:13:24:22 | 'aCommand' |
| tst.js:24:25:24:36 | ['example1'] |
| tst.js:26:13:26:18 | 'echo' |
| tst.js:26:21:26:32 | ['example1'] |
| tst.js:28:13:28:47 | 'echo e ... ple 11' |
| tst.js:28:50:28:64 | { shell: true } |
| tst.js:29:13:29:29 | 'echo example 10' |
| tst.js:29:32:29:52 | ['; ech ... le 11'] |
| tst.js:29:55:29:69 | { shell: true } |
| tst.js:32:11:32:16 | 'echo' |
| tst.js:32:19:32:35 | ['example5 sync'] |
| tst.js:34:20:34:42 | "echo " ... gument" |
| tst.js:35:20:35:52 | `echo $ ... ndSync` |
| tst.js:37:18:37:20 | arg |
| tst.js:39:18:39:39 | "echo 1 ... echo 2" |
| tst.js:39:42:39:56 | { shell: true } |
| tst.js:45:9:45:27 | { inputFile: file } |
| tst.js:46:13:46:17 | 'cat' |
| tst.js:46:20:46:38 | { inputFile: file } |
| tst.js:47:13:47:18 | 'echo' |
| tst.js:47:21:47:32 | ['example2'] |
| tst.js:48:13:48:18 | 'echo' |
| tst.js:48:21:48:32 | ['example3'] |
| tst.js:49:13:49:18 | 'echo' |
| tst.js:49:21:49:32 | ['example4'] |
| tst.js:49:35:49:47 | { all: true } |
test_MissingFileSystemAccess
| tst.js:43:35:43:38 | file |
| tst.js:47:46:47:49 | file |
| tst.js:48:46:48:49 | file |
| tst.js:49:58:49:61 | file |
test_SystemCommandExecution
| tst.js:1:71:1:71 | $ |
| tst.js:4:7:4:7 | $ |
| tst.js:5:7:5:7 | $ |
| tst.js:6:1:6:1 | $ |
| tst.js:6:1:6:6 | $.sync |
| tst.js:10:7:10:7 | $ |
| tst.js:12:7:12:7 | $ |
| tst.js:13:1:13:1 | $ |
| tst.js:13:1:13:6 | $.sync |
| tst.js:15:1:15:1 | $ |
| tst.js:15:1:15:6 | $.sync |
| tst.js:16:7:16:7 | $ |
| tst.js:18:7:18:7 | $ |
| tst.js:18:7:18:24 | $({ shell: true }) |
| tst.js:20:7:20:7 | $ |
| tst.js:20:7:20:25 | $({ shell: false }) |
| tst.js:24:7:24:37 | execa(' ... ple1']) |
| tst.js:26:7:26:33 | execa(' ... ple1']) |
| tst.js:28:7:28:65 | execa(' ... true }) |
| tst.js:29:7:29:70 | execa(' ... true }) |
| tst.js:32:1:32:36 | execaSy ... sync']) |
| tst.js:34:7:34:43 | execaCo ... ument") |
| tst.js:35:7:35:53 | execaCo ... dSync`) |
| tst.js:37:1:37:21 | execaCo ... nc(arg) |
| tst.js:39:1:39:57 | execaCo ... true }) |
| tst.js:43:7:43:7 | $ |
| tst.js:45:7:45:7 | $ |
| tst.js:45:7:45:28 | $({ inp ... file }) |
| tst.js:46:7:46:39 | execa(' ... file }) |
| tst.js:47:7:47:33 | execa(' ... ple2']) |
| tst.js:48:7:48:33 | execa(' ... ple3']) |
| tst.js:49:7:49:48 | execa(' ... true }) |
test_FileNameSource
12 changes: 12 additions & 0 deletions javascript/ql/test/library-tests/frameworks/Execa/Execa.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import javascript

query predicate test_FileSystemAccess(FileSystemAccess access) { any() }

query predicate test_MissingFileSystemAccess(VarAccess var) {
var.getName().matches("file%") and
not exists(FileSystemAccess access | access.getAPathArgument().asExpr() = var)
}

query predicate test_SystemCommandExecution(SystemCommandExecution exec) { any() }

query predicate test_FileNameSource(FileNameSource exec) { any() }
Loading