Skip to content

Commit a20ca78

Browse files
committed
V1
1 parent 38892bb commit a20ca78

File tree

5 files changed

+364
-0
lines changed

5 files changed

+364
-0
lines changed

javascript/ql/lib/javascript.qll

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ import semmle.javascript.frameworks.Request
123123
import semmle.javascript.frameworks.RxJS
124124
import semmle.javascript.frameworks.ServerLess
125125
import semmle.javascript.frameworks.ShellJS
126+
import semmle.javascript.frameworks.Execa
126127
import semmle.javascript.frameworks.Snapdragon
127128
import semmle.javascript.frameworks.SystemCommandExecutors
128129
import semmle.javascript.frameworks.SQL
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/**
2+
* Models the `execa` library in terms of `FileSystemAccess` and `SystemCommandExecution`.
3+
*/
4+
5+
import javascript
6+
import semmle.javascript.security.dataflow.RequestForgeryCustomizations
7+
import semmle.javascript.security.dataflow.UrlConcatenation
8+
9+
/**
10+
* Provide model for [Execa](https://github.com/sindresorhus/execa) package
11+
*/
12+
module Execa {
13+
/**
14+
* The Execa input file option
15+
*/
16+
class ExecaRead extends FileSystemReadAccess, DataFlow::Node {
17+
API::Node execaNode;
18+
19+
ExecaRead() {
20+
(
21+
execaNode = API::moduleImport("execa").getMember("$").getParameter(0)
22+
or
23+
execaNode =
24+
API::moduleImport("execa")
25+
.getMember(["execa", "execaCommand", "execaCommandSync", "execaSync"])
26+
.getParameter([0, 1, 2])
27+
) and
28+
this = execaNode.asSink()
29+
}
30+
31+
// data is the output of a command so IDK how it can be implemented
32+
override DataFlow::Node getADataNode() { none() }
33+
34+
override DataFlow::Node getAPathArgument() {
35+
result = execaNode.getMember("inputFile").asSink()
36+
}
37+
}
38+
39+
/**
40+
* A call to `execa.execa` or `execa.execaSync`
41+
*/
42+
class ExecaCall extends API::CallNode {
43+
string name;
44+
45+
ExecaCall() {
46+
this = API::moduleImport("execa").getMember("execa").getACall() and
47+
name = "execa"
48+
or
49+
this = API::moduleImport("execa").getMember("execaSync").getACall() and
50+
name = "execaSync"
51+
}
52+
53+
/** Gets the name of the exported function, such as `rm` in `shelljs.rm()`. */
54+
string getName() { result = name }
55+
}
56+
57+
/**
58+
* The system command execution nodes for `execa.execa` or `execa.execaSync` functions
59+
*/
60+
class ExecaExec extends SystemCommandExecution, ExecaCall {
61+
ExecaExec() { name = ["execa", "execaSync"] }
62+
63+
override DataFlow::Node getACommandArgument() { result = this.getArgument(0) }
64+
65+
override predicate isShellInterpreted(DataFlow::Node arg) {
66+
// if shell: true then first and second args are sinks
67+
// options can be third argument
68+
arg = [this.getArgument(0), this.getParameter(1).getUnknownMember().asSink()] and
69+
isExecaShellEnable(this.getParameter(2))
70+
or
71+
// options can be second argument
72+
arg = this.getArgument(0) and
73+
isExecaShellEnable(this.getParameter(1))
74+
}
75+
76+
override predicate isSync() { name = "execaSync" }
77+
78+
override DataFlow::Node getOptionsArg() {
79+
result = this.getLastArgument() and result.asExpr() instanceof ObjectExpr
80+
}
81+
}
82+
83+
/**
84+
* A call to `execa.$` or `execa.$.sync` tag functions
85+
*/
86+
private class ExecaScriptExpr extends DataFlow::ExprNode {
87+
string name;
88+
89+
ExecaScriptExpr() {
90+
this.asExpr() =
91+
[
92+
API::moduleImport("execa").getMember("$"),
93+
API::moduleImport("execa").getMember("$").getReturn()
94+
].getAValueReachableFromSource().asExpr() and
95+
name = "ASync"
96+
or
97+
this.asExpr() =
98+
[
99+
API::moduleImport("execa").getMember("$").getMember("sync"),
100+
API::moduleImport("execa").getMember("$").getMember("sync").getReturn()
101+
].getAValueReachableFromSource().asExpr() and
102+
name = "Sync"
103+
}
104+
105+
/** Gets the name of the exported function, such as `rm` in `shelljs.rm()`. */
106+
string getName() { result = name }
107+
}
108+
109+
/**
110+
* The system command execution nodes for `execa.$` or `execa.$.sync` tag functions
111+
*/
112+
class ExecaScriptEec extends SystemCommandExecution, ExecaScriptExpr {
113+
ExecaScriptEec() { name = ["Sync", "ASync"] }
114+
115+
override DataFlow::Node getACommandArgument() {
116+
result.asExpr() = templateLiteralChildAsSink(this.asExpr()).getChildExpr(0)
117+
}
118+
119+
override predicate isShellInterpreted(DataFlow::Node arg) {
120+
// $({shell: true})`${sink} ${sink} .. ${sink}`
121+
// ISSUE: $`cmd args` I can't reach the tag function argument easily
122+
exists(TemplateLiteral tmpL | templateLiteralChildAsSink(this.asExpr()) = tmpL |
123+
arg.asExpr() = tmpL.getAChildExpr+() and
124+
isExecaShellEnableWithExpr(this.asExpr().(CallExpr).getArgument(0))
125+
)
126+
}
127+
128+
override DataFlow::Node getArgumentList() {
129+
// $`${Can Not Be sink} ${sink} .. ${sink}`
130+
exists(TemplateLiteral tmpL | templateLiteralChildAsSink(this.asExpr()) = tmpL |
131+
result.asExpr() = tmpL.getAChildExpr+() and
132+
not result.asExpr() = tmpL.getChildExpr(0)
133+
)
134+
}
135+
136+
override predicate isSync() { name = "Sync" }
137+
138+
override DataFlow::Node getOptionsArg() {
139+
result = this.asExpr().getAChildExpr*().flow() and result.asExpr() instanceof ObjectExpr
140+
}
141+
}
142+
143+
/**
144+
* A call to `execa.execaCommandSync` or `execa.execaCommand`
145+
*/
146+
private class ExecaCommandCall extends API::CallNode {
147+
string name;
148+
149+
ExecaCommandCall() {
150+
this = API::moduleImport("execa").getMember("execaCommandSync").getACall() and
151+
name = "execaCommandSync"
152+
or
153+
this = API::moduleImport("execa").getMember("execaCommand").getACall() and
154+
name = "execaCommand"
155+
}
156+
157+
/** Gets the name of the exported function, such as `rm` in `shelljs.rm()`. */
158+
string getName() { result = name }
159+
}
160+
161+
/**
162+
* The system command execution nodes for `execa.execaCommand` or `execa.execaCommandSync` functions
163+
*/
164+
class ExecaCommandExec2 extends SystemCommandExecution, DataFlow::CallNode {
165+
ExecaCommandExec2() { this = API::moduleImport("execa").getMember("execaCommand").getACall() }
166+
167+
override DataFlow::Node getACommandArgument() { result = this.getArgument(0) }
168+
169+
override DataFlow::Node getArgumentList() { result = this.getArgument(0) }
170+
171+
override predicate isShellInterpreted(DataFlow::Node arg) { arg = this.getArgument(0) }
172+
173+
override predicate isSync() { none() }
174+
175+
override DataFlow::Node getOptionsArg() { result = this }
176+
}
177+
178+
/**
179+
* The system command execution nodes for `execa.execaCommand` or `execa.execaCommandSync` functions
180+
*/
181+
class ExecaCommandExec extends SystemCommandExecution, ExecaCommandCall {
182+
ExecaCommandExec() { name = ["execaCommand", "execaCommandSync"] }
183+
184+
override DataFlow::Node getACommandArgument() {
185+
result = this.(DataFlow::CallNode).getArgument(0)
186+
}
187+
188+
override DataFlow::Node getArgumentList() {
189+
// execaCommand("echo " + sink);
190+
// execaCommand(`echo ${sink}`);
191+
result.asExpr() = this.getParameter(0).asSink().asExpr().getAChildExpr+() and
192+
not result.asExpr() = this.getArgument(0).asExpr().getChildExpr(0)
193+
}
194+
195+
override predicate isShellInterpreted(DataFlow::Node arg) {
196+
// execaCommandSync(sink1 + sink2, {shell: true})
197+
arg.asExpr() = this.getArgument(0).asExpr().getAChildExpr+() and
198+
isExecaShellEnable(this.getParameter(1))
199+
or
200+
// there is only one argument that is constructed in previous nodes,
201+
// it makes sanitizing really hard to select whether it is vulnerable to argument injection or not
202+
arg = this.getParameter(0).asSink() and
203+
not exists(this.getArgument(0).asExpr().getChildExpr(1))
204+
}
205+
206+
override predicate isSync() { name = "execaCommandSync" }
207+
208+
override DataFlow::Node getOptionsArg() {
209+
result = this.getLastArgument() and result.asExpr() instanceof ObjectExpr
210+
}
211+
}
212+
213+
// Holds if left parameter is the left child of a template literal and returns the template literal
214+
private TemplateLiteral templateLiteralChildAsSink(Expr left) {
215+
exists(TaggedTemplateExpr parent |
216+
parent.getTemplate() = result and
217+
left = parent.getChildExpr(0)
218+
)
219+
}
220+
221+
// Holds whether Execa has shell enabled options or not, get Parameter responsible for options
222+
private predicate isExecaShellEnable(API::Node n) {
223+
n.getMember("shell").asSink().asExpr().(BooleanLiteral).getValue() = "true"
224+
}
225+
226+
// Holds whether Execa has shell enabled options or not, get Parameter responsible for options
227+
private predicate isExecaShellEnableWithExpr(Expr n) {
228+
exists(ObjectExpr o, Property p | o = n.getAChildExpr*() |
229+
o.getAChild() = p and
230+
p.getAChild().(Label).getName() = "shell" and
231+
p.getAChild().(Literal).getValue() = "true"
232+
)
233+
}
234+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
test_FileSystemAccess
2+
| tst.js:18:9:18:23 | { shell: true } |
3+
| tst.js:20:9:20:24 | { shell: false } |
4+
| tst.js:24:13:24:22 | 'aCommand' |
5+
| tst.js:24:25:24:36 | ['example1'] |
6+
| tst.js:26:13:26:18 | 'echo' |
7+
| tst.js:26:21:26:32 | ['example1'] |
8+
| tst.js:28:13:28:47 | 'echo e ... ple 11' |
9+
| tst.js:28:50:28:64 | { shell: true } |
10+
| tst.js:29:13:29:29 | 'echo example 10' |
11+
| tst.js:29:32:29:52 | ['; ech ... le 11'] |
12+
| tst.js:29:55:29:69 | { shell: true } |
13+
| tst.js:32:11:32:16 | 'echo' |
14+
| tst.js:32:19:32:35 | ['example5 sync'] |
15+
| tst.js:34:20:34:42 | "echo " ... gument" |
16+
| tst.js:35:20:35:52 | `echo $ ... ndSync` |
17+
| tst.js:37:18:37:20 | arg |
18+
| tst.js:39:18:39:39 | "echo 1 ... echo 2" |
19+
| tst.js:39:42:39:56 | { shell: true } |
20+
| tst.js:45:9:45:27 | { inputFile: file } |
21+
| tst.js:46:13:46:17 | 'cat' |
22+
| tst.js:46:20:46:38 | { inputFile: file } |
23+
| tst.js:47:13:47:18 | 'echo' |
24+
| tst.js:47:21:47:32 | ['example2'] |
25+
| tst.js:48:13:48:18 | 'echo' |
26+
| tst.js:48:21:48:32 | ['example3'] |
27+
| tst.js:49:13:49:18 | 'echo' |
28+
| tst.js:49:21:49:32 | ['example4'] |
29+
| tst.js:49:35:49:47 | { all: true } |
30+
test_MissingFileSystemAccess
31+
| tst.js:43:35:43:38 | file |
32+
| tst.js:47:46:47:49 | file |
33+
| tst.js:48:46:48:49 | file |
34+
| tst.js:49:58:49:61 | file |
35+
test_SystemCommandExecution
36+
| tst.js:1:71:1:71 | $ |
37+
| tst.js:4:7:4:7 | $ |
38+
| tst.js:5:7:5:7 | $ |
39+
| tst.js:6:1:6:1 | $ |
40+
| tst.js:6:1:6:6 | $.sync |
41+
| tst.js:10:7:10:7 | $ |
42+
| tst.js:12:7:12:7 | $ |
43+
| tst.js:13:1:13:1 | $ |
44+
| tst.js:13:1:13:6 | $.sync |
45+
| tst.js:15:1:15:1 | $ |
46+
| tst.js:15:1:15:6 | $.sync |
47+
| tst.js:16:7:16:7 | $ |
48+
| tst.js:18:7:18:7 | $ |
49+
| tst.js:18:7:18:24 | $({ shell: true }) |
50+
| tst.js:20:7:20:7 | $ |
51+
| tst.js:20:7:20:25 | $({ shell: false }) |
52+
| tst.js:24:7:24:37 | execa(' ... ple1']) |
53+
| tst.js:26:7:26:33 | execa(' ... ple1']) |
54+
| tst.js:28:7:28:65 | execa(' ... true }) |
55+
| tst.js:29:7:29:70 | execa(' ... true }) |
56+
| tst.js:32:1:32:36 | execaSy ... sync']) |
57+
| tst.js:34:7:34:43 | execaCo ... ument") |
58+
| tst.js:35:7:35:53 | execaCo ... dSync`) |
59+
| tst.js:37:1:37:21 | execaCo ... nc(arg) |
60+
| tst.js:39:1:39:57 | execaCo ... true }) |
61+
| tst.js:43:7:43:7 | $ |
62+
| tst.js:45:7:45:7 | $ |
63+
| tst.js:45:7:45:28 | $({ inp ... file }) |
64+
| tst.js:46:7:46:39 | execa(' ... file }) |
65+
| tst.js:47:7:47:33 | execa(' ... ple2']) |
66+
| tst.js:48:7:48:33 | execa(' ... ple3']) |
67+
| tst.js:49:7:49:48 | execa(' ... true }) |
68+
test_FileNameSource
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import javascript
2+
3+
query predicate test_FileSystemAccess(FileSystemAccess access) { any() }
4+
5+
query predicate test_MissingFileSystemAccess(VarAccess var) {
6+
var.getName().matches("file%") and
7+
not exists(FileSystemAccess access | access.getAPathArgument().asExpr() = var)
8+
}
9+
10+
query predicate test_SystemCommandExecution(SystemCommandExecution exec) { any() }
11+
12+
query predicate test_FileNameSource(FileNameSource exec) { any() }
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { execa, execaSync, execaCommand, execaCommandSync, execaNode, $ } from 'execa';
2+
3+
// Node.js scripts
4+
await $`echo example1`.pipeStderr(`tmp`);
5+
await $`echo ${"example2"}`.pipeStderr(`tmp`);
6+
$.sync`echo example2 sync`
7+
// Multiple arguments
8+
const args = ["arg:" + arg, 'example3', '&', 'rainbows!'];
9+
// GOOD
10+
await $`${arg} sth`;
11+
// GOOD only one command can be executed
12+
await $`${arg}`;
13+
$.sync`${arg}`
14+
// BAD argument injection
15+
$.sync`echo ${args} ${args}`
16+
await $`echo ${["-a", "-lps"]}`
17+
// if shell: true then all inputs except first are dangerous
18+
await $({ shell: true })`echo example6 ${";echo example6 > tmpdir/example6"}`
19+
// GOOD
20+
await $({ shell: false })`echo example6 ${";echo example6 > tmpdir/example6"}`
21+
22+
// execa
23+
// GOOD
24+
await execa('aCommand', ['example1']);
25+
// BAD argument injection
26+
await execa('echo', ['example1']);
27+
// BAD shell is enable
28+
await execa('echo example 10 ; echo example 11', { shell: true });
29+
await execa('echo example 10', ['; echo example 11'], { shell: true });
30+
31+
// BAD argument injection
32+
execaSync('echo', ['example5 sync']);
33+
// BAD argument injection
34+
await execaCommand("echo " + "badArgument");
35+
await execaCommand(`echo ${"arg1"} execaCommandSync`);
36+
// bad totally controllable argument
37+
execaCommandSync(arg);
38+
// BAD shell is enable
39+
execaCommandSync("echo 1 " + "; echo 2", { shell: true });
40+
41+
// FileSystemAccess
42+
// Piping stdout to a file
43+
await $`echo example8`.pipeStdout(file)
44+
// Piping stdin from a file
45+
await $({ inputFile: file })`cat`
46+
await execa('cat', { inputFile: file });
47+
await execa('echo', ['example2']).pipeStdout(file);
48+
await execa('echo', ['example3']).pipeStderr(file);
49+
await execa('echo', ['example4'], { all: true }).pipeAll(file);

0 commit comments

Comments
 (0)