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: provide command execution sinks for execa package #14294

Merged
merged 14 commits into from
May 24, 2024
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
206 changes: 206 additions & 0 deletions javascript/ql/lib/semmle/javascript/frameworks/Execa.qll
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* Models the `execa` library in terms of `FileSystemAccess` and `SystemCommandExecution`.
*/

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

/**
* Provide model for [Execa](https://github.com/sindresorhus/execa) package
*/
module Execa {
/**
* The Execa input file read and output file write
*/
class ExecaFileSystemAccess extends FileSystemReadAccess, DataFlow::Node {
API::Node execaArg;
boolean isPipedToFile;

ExecaFileSystemAccess() {
(
execaArg = API::moduleImport("execa").getMember("$").getParameter(0) and
isPipedToFile = false
or
execaArg =
API::moduleImport("execa")
.getMember(["execa", "execaCommand", "execaCommandSync", "execaSync"])
.getParameter([0, 1, 2]) and
isPipedToFile = false
or
execaArg =
API::moduleImport("execa")
.getMember(["execa", "execaCommand", "execaCommandSync", "execaSync"])
.getReturn()
.getMember(["pipeStdout", "pipeAll", "pipeStderr"])
.getParameter(0) and
isPipedToFile = true
) and
this = execaArg.asSink()
}

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

override DataFlow::Node getAPathArgument() {
result = execaArg.getMember("inputFile").asSink() and isPipedToFile = false
or
result = execaArg.asSink() and isPipedToFile = true
}
}

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

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

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

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 DataFlow::Node getArgumentList() {
// execa(cmd, [arg]);
exists(DataFlow::Node arg | arg = this.getArgument(1) |
// if it is a object then it is a option argument not command argument
result = arg and not arg.asExpr() instanceof ObjectExpr
)
}

override predicate isSync() { isSync = true }

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

/**
* A call to `execa.$` or `execa.$.sync` or `execa.$({})` or `execa.$.sync({})` tag functions
*/
private class ExecaScriptCall extends API::CallNode {
boolean isSync;

ExecaScriptCall() {
exists(API::Node script |
script =
[
API::moduleImport("execa").getMember("$"),
API::moduleImport("execa").getMember("$").getReturn()
]
|
this = script.getACall() and
isSync = false
or
this = script.getMember("sync").getACall() and
isSync = true
)
}
}

/**
* The system command execution nodes for `execa.$` or `execa.$.sync` tag functions
*/
class ExecaScript extends SystemCommandExecution, ExecaScriptCall {
ExecaScript() { isSync = [false, true] }

override DataFlow::Node getACommandArgument() { result = this.getParameter(1).asSink() }

override predicate isShellInterpreted(DataFlow::Node arg) {
isExecaShellEnable(this.getParameter(0)) and
arg = this.getAParameter().asSink()
}

override DataFlow::Node getArgumentList() {
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
result = this.getParameter(any(int i | i > 2)).asSink() and
// here I should check if the first parameter of Template literal is the rightmost string of this Template literal then the arguments of this command execution will be the second and third and .. parameters
not exists(string s | this.getACall().getArgument(0).mayHaveStringValue(s) | s.matches(""))
Fixed Show fixed Hide fixed
or
result = this.getParameter(any(int i | i > 1)).asSink() and
// here I should check if the first parameter of Template literal is a constant which is the command, then the arguments of this command execution will be the first, second and third and .. parameters
not exists(string s | this.getACall().getArgument(0).mayHaveStringValue(s) | s.matches(""))
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
Fixed Show fixed Hide fixed
}

override DataFlow::Node getOptionsArg() { result = this.getParameter(0).asSink() }

override predicate isSync() { isSync = true }
}

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

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

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

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

override DataFlow::Node getArgumentList() {
// execaCommand(`${cmd} ${arg}`);
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(`${cmd} ${arg}`, {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() { isSync = true }

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

/**
* Holds whether Execa has shell enabled options or not, get Parameter responsible for options
*/
pragma[inline]
private predicate isExecaShellEnable(API::Node n) {
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
n.getMember("shell").asSink().asExpr().(BooleanLiteral).getValue() = "true"
}
}
Loading