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
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;
am0o0 marked this conversation as resolved.
Show resolved Hide resolved

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() }
am0o0 marked this conversation as resolved.
Show resolved Hide resolved

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 }
}
am0o0 marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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" }
am0o0 marked this conversation as resolved.
Show resolved Hide resolved

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
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
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"] }
am0o0 marked this conversation as resolved.
Show resolved Hide resolved

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() {
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
// $`${Can Not Be sink} ${sink} .. ${sink}`
exists(TemplateLiteral tmpL | templateLiteralChildAsSink(this.asExpr()) = tmpL |
result.asExpr() = tmpL.getAChildExpr+() and
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
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
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
* 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 {
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
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) {
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
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) {
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
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"
)
}
}
68 changes: 68 additions & 0 deletions javascript/ql/test/library-tests/frameworks/Execa/Execa.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
test_FileSystemAccess
| tst.js:22:9:22:23 | { shell: true } |
| tst.js:24:9:24:24 | { shell: false } |
| tst.js:28:13:28:22 | 'aCommand' |
| tst.js:28:25:28:36 | ['example1'] |
| tst.js:30:13:30:17 | 'git' |
| tst.js:30:20:30:31 | ['example1'] |
| tst.js:32:13:32:47 | 'echo e ... ple 11' |
| tst.js:32:50:32:64 | { shell: true } |
| tst.js:33:13:33:29 | 'echo example 10' |
| tst.js:33:32:33:52 | ['; ech ... le 11'] |
| tst.js:33:55:33:69 | { shell: true } |
| tst.js:36:11:36:16 | 'echo' |
| tst.js:36:19:36:35 | ['example5 sync'] |
| tst.js:38:20:38:41 | "git " ... gument" |
| tst.js:39:20:39:51 | `git ${ ... ndSync` |
| tst.js:41:18:41:20 | arg |
| tst.js:43:18:43:39 | "echo 1 ... echo 2" |
| tst.js:43:42:43:56 | { shell: true } |
| tst.js:49:9:49:27 | { inputFile: file } |
| tst.js:50:13:50:17 | 'cat' |
| tst.js:50:20:50:38 | { inputFile: file } |
| tst.js:51:13:51:18 | 'echo' |
| tst.js:51:21:51:32 | ['example2'] |
| tst.js:52:13:52:18 | 'echo' |
| tst.js:52:21:52:32 | ['example3'] |
| tst.js:53:13:53:18 | 'echo' |
| tst.js:53:21:53:32 | ['example4'] |
| tst.js:53:35:53:47 | { all: true } |
test_MissingFileSystemAccess
| tst.js:47:35:47:38 | file |
| tst.js:51:46:51:49 | file |
| tst.js:52:46:52:49 | file |
| tst.js:53:58:53:61 | file |
test_SystemCommandExecution
| tst.js:1:71:1:71 | $ |
| tst.js:7:7:7:7 | $ |
| tst.js:9:7:9:7 | $ |
| tst.js:10:1:10:1 | $ |
| tst.js:10:1:10:6 | $.sync |
| tst.js:14:7:14:7 | $ |
| tst.js:16:7:16:7 | $ |
| tst.js:17:1:17:1 | $ |
| tst.js:17:1:17:6 | $.sync |
| tst.js:19:1:19:1 | $ |
| tst.js:19:1:19:6 | $.sync |
| tst.js:20:7:20:7 | $ |
| tst.js:22:7:22:7 | $ |
| tst.js:22:7:22:24 | $({ shell: true }) |
| tst.js:24:7:24:7 | $ |
| tst.js:24:7:24:25 | $({ shell: false }) |
| tst.js:28:7:28:37 | execa(' ... ple1']) |
| tst.js:30:7:30:32 | execa(' ... ple1']) |
| tst.js:32:7:32:65 | execa(' ... true }) |
| tst.js:33:7:33:70 | execa(' ... true }) |
| tst.js:36:1:36:36 | execaSy ... sync']) |
| tst.js:38:7:38:42 | execaCo ... ument") |
| tst.js:39:7:39:52 | execaCo ... dSync`) |
| tst.js:41:1:41:21 | execaCo ... nc(arg) |
| tst.js:43:1:43:57 | execaCo ... true }) |
| tst.js:47:7:47:7 | $ |
| tst.js:49:7:49:7 | $ |
| tst.js:49:7:49:28 | $({ inp ... file }) |
| tst.js:50:7:50:39 | execa(' ... file }) |
| tst.js:51:7:51:33 | execa(' ... ple2']) |
| tst.js:52:7:52:33 | execa(' ... ple3']) |
| tst.js:53:7:53: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() }
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
53 changes: 53 additions & 0 deletions javascript/ql/test/library-tests/frameworks/Execa/tst.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { execa, execaSync, execaCommand, execaCommandSync, execaNode, $ } from 'execa';

const arg = process.argv[0];

// Node.js scripts
// GOOD
await $`echo example1`.pipeStderr(`tmp`);
// BAD argument injection
await $`ssh ${"example2"}`.pipeStderr(`tmp`);
am0o0 marked this conversation as resolved.
Show resolved Hide resolved
$.sync`echo example2 sync`
// Multiple arguments
const args = ["arg:" + arg, 'example3', '&', 'rainbows!'];
// GOOD
await $`${arg} sth`;
// GOOD only one command can be executed
await $`${arg}`;
$.sync`${arg}`
// BAD argument injection
$.sync`git ${args} ${args}`
await $`git ${["-o", "-lps"]}`
// if shell: true then all inputs except first are dangerous
await $({ shell: true })`echo example6 ${";echo example6 > tmpdir/example6"}`
// GOOD
await $({ shell: false })`echo example6 ${";echo example6 > tmpdir/example6"}`

// execa
// GOOD
await execa('aCommand', ['example1']);
// BAD argument injection
await execa('git', ['example1']);
// BAD shell is enable
await execa('echo example 10 ; echo example 11', { shell: true });
await execa('echo example 10', ['; echo example 11'], { shell: true });

// BAD argument injection
execaSync('echo', ['example5 sync']);
// BAD argument injection
await execaCommand("git " + "badArgument");
await execaCommand(`git ${"arg1"} execaCommandSync`);
// bad totally controllable argument
execaCommandSync(arg);
// BAD shell is enable
execaCommandSync("echo 1 " + "; echo 2", { shell: true });

// FileSystemAccess
// Piping stdout to a file
await $`echo example8`.pipeStdout(file)
// Piping stdin from a file
await $({ inputFile: file })`cat`
await execa('cat', { inputFile: file });
await execa('echo', ['example2']).pipeStdout(file);
await execa('echo', ['example3']).pipeStderr(file);
await execa('echo', ['example4'], { all: true }).pipeAll(file);