Skip to content

Commit 13637ec

Browse files
committed
JavaScript: preserve sanitizer barriers on guarded phi inputs
1 parent 79ef8bc commit 13637ec

13 files changed

Lines changed: 177 additions & 41 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* Improved sanitizer-guard handling for JavaScript and TypeScript variables that are assigned a safe fallback value, reducing false-positive security alerts.

‎javascript/ql/lib/semmle/javascript/dataflow/Configuration.qll‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,8 @@ deprecated private predicate barrierGuardBlocksEdge(
526526
|
527527
bb = getADominatedBasicBlock(guard, cond) and
528528
pred = DataFlow::ssaDefinitionNode(input) and
529-
succ = DataFlow::ssaDefinitionNode(phi) and
529+
succ.(DataFlowPrivate::SsaPhiInputNode).getPhi() = phi and
530+
succ.(DataFlowPrivate::SsaPhiInputNode).getInputBlock() = bb and
530531
input = phi.getInputFromBlock(bb) and
531532
outcome = cond.getOutcome() and
532533
barrierGuardBlocksExpr(guard, outcome, input.getAUse(), label)

‎javascript/ql/lib/semmle/javascript/dataflow/DataFlow.qll‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1740,10 +1740,26 @@ module DataFlow {
17401740
succ = TSsaDefNode(ssaCapture)
17411741
)
17421742
or
1743-
// Flow through implicit SSA nodes
1744-
exists(SsaImplicitDefinition ssa | succ = TSsaDefNode(ssa) |
1745-
// from the inputs of phi and pi nodes into the node itself
1746-
pred = TSsaDefNode(ssa.(SsaPseudoDefinition).getAnInput().getDefinition())
1743+
// Flow into a phi through a separate node when an incoming branch can be
1744+
// controlled by a guard, allowing barriers to block just the checked input.
1745+
// Other phi inputs keep the direct edge to avoid unnecessary flow nodes.
1746+
exists(SsaPhiNode phi, BasicBlock bb |
1747+
pred = TSsaDefNode(phi.getInputFromBlock(bb).getDefinition()) and
1748+
succ = TSsaPhiInputNode(phi, bb)
1749+
or
1750+
pred = TSsaPhiInputNode(phi, bb) and
1751+
succ = TSsaDefNode(phi)
1752+
or
1753+
pred = TSsaDefNode(phi.getInputFromBlock(bb).getDefinition()) and
1754+
succ = TSsaDefNode(phi) and
1755+
not exists(TSsaPhiInputNode(phi, bb))
1756+
)
1757+
or
1758+
// Other pseudo-definitions, such as SSA refinements, retain their usual flow.
1759+
exists(SsaPseudoDefinition ssa |
1760+
not ssa instanceof SsaPhiNode and
1761+
pred = TSsaDefNode(ssa.getAnInput().getDefinition()) and
1762+
succ = TSsaDefNode(ssa)
17471763
)
17481764
or
17491765
exists(Expr predExpr, Expr succExpr |

‎javascript/ql/lib/semmle/javascript/dataflow/internal/BarrierGuards.qll‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,21 @@ module MakeStateBarrierGuard<
313313
)
314314
}
315315

316+
/** Blocks only the incoming phi branch where the checked value is safe. */
317+
pragma[nomagic]
318+
private predicate barrierGuardBlocksPhiInput(
319+
BarrierGuard guard, SsaPhiNode phi, BasicBlock bb, FlowState state
320+
) {
321+
exists(ConditionGuardNode cond, boolean outcome, SsaVariable input |
322+
barrierGuardIsRelevant(guard) and
323+
guard.getEnclosingExpr() = cond.getTest() and
324+
cond.dominates(bb) and
325+
outcome = cond.getOutcome() and
326+
input = phi.getInputFromBlock(bb) and
327+
barrierGuardBlocksExpr(guard, outcome, input.getAUse(), state)
328+
)
329+
}
330+
316331
/**
317332
* Holds if the result of `guard` is used in the branching condition `cond`.
318333
*
@@ -389,6 +404,11 @@ module MakeStateBarrierGuard<
389404
nd = DataFlow::valueNode(use)
390405
)
391406
or
407+
exists(BarrierGuard guard, DataFlowPrivate::SsaPhiInputNode input |
408+
nd = input and
409+
barrierGuardBlocksPhiInput(guard, input.getPhi(), input.getInputBlock(), state)
410+
)
411+
or
392412
ssa2BlocksNode(nd, state)
393413
}
394414

‎javascript/ql/lib/semmle/javascript/dataflow/internal/DataFlowNode.qll‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ private module Cached {
3434
TValueNode(AST::ValueNode nd) or
3535
/** An SSA node from the legacy SSA library */
3636
TSsaDefNode(SsaDefinition d) or
37+
/** The input to a phi node from one predecessor block, for branch-specific barriers. */
38+
TSsaPhiInputNode(SsaPhiNode phi, BasicBlock predecessor) {
39+
exists(phi.getInputFromBlock(predecessor)) and
40+
// Only split inputs whose incoming branch is controlled by a condition.
41+
// Other inputs retain their direct SSA flow edge.
42+
exists(ConditionGuardNode guard | guard.dominates(predecessor))
43+
} or
3744
/** Use of a variable or 'this', with flow from a post-update node (from an earlier use) */
3845
TSsaUseNode(ControlFlowNode use) { use = any(Ssa2::SsaConfig::SourceVariable v).getAUse() } or
3946
/** A synthesized variable read (new SSA library). Definitions are represented by TSsaDefNode. */
@@ -130,8 +137,8 @@ private module Cached {
130137
import Cached
131138

132139
private class TEarlyStageNode =
133-
TValueNode or TSsaDefNode or TCapturedVariableNode or TPropNode or TRestPatternNode or
134-
TElementPatternNode or TElementNode or TReflectiveCallNode or TThisNode or
140+
TValueNode or TSsaDefNode or TSsaPhiInputNode or TCapturedVariableNode or TPropNode or
141+
TRestPatternNode or TElementPatternNode or TElementNode or TReflectiveCallNode or TThisNode or
135142
TFunctionSelfReferenceNode or TDestructuredModuleImportNode or THtmlAttributeNode or
136143
TFunctionReturnNode or TExceptionalFunctionReturnNode or TExceptionalInvocationReturnNode or
137144
TGlobalAccessPathRoot or TTemplatePlaceholderTag or TReflectiveParametersNode or

‎javascript/ql/lib/semmle/javascript/dataflow/internal/DataFlowPrivate.qll‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,27 @@ class SsaUseNode extends DataFlow::Node, TSsaUseNode {
3838
override Location getLocation() { result = expr.getLocation() }
3939
}
4040

41+
/** One incoming edge of an SSA phi, represented as a node so guards can block it. */
42+
class SsaPhiInputNode extends DataFlow::Node, TSsaPhiInputNode {
43+
private SsaPhiNode phi;
44+
private BasicBlock predecessor;
45+
46+
SsaPhiInputNode() { this = TSsaPhiInputNode(phi, predecessor) }
47+
48+
SsaPhiNode getPhi() { result = phi }
49+
50+
BasicBlock getInputBlock() { result = predecessor }
51+
52+
cached
53+
override string toString() { result = "[ssa-phi-input] " + phi.toString() }
54+
55+
cached
56+
override StmtContainer getContainer() { result = phi.getSourceVariable().getDeclaringContainer() }
57+
58+
cached
59+
override Location getLocation() { result = predecessor.getLocation() }
60+
}
61+
4162
class SsaSynthReadNode extends DataFlow::Node, TSsaSynthReadNode {
4263
private Ssa2::SsaSynthReadNode read;
4364

‎javascript/ql/test/library-tests/TaintTracking/BasicTaintTracking.expected‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ legacyDataFlowDifference
3131
| object-bypass-sanitizer.js:35:29:35:36 | source() | object-bypass-sanitizer.js:23:14:23:20 | obj.foo | only flow with OLD data flow library |
3232
| object-bypass-sanitizer.js:35:29:35:36 | source() | object-bypass-sanitizer.js:28:10:28:30 | sanitiz ... bj).foo | only flow with OLD data flow library |
3333
| promise.js:12:20:12:27 | source() | promise.js:13:8:13:23 | resolver.promise | only flow with OLD data flow library |
34-
| sanitizer-guards.js:57:11:57:18 | source() | sanitizer-guards.js:64:8:64:8 | x | only flow with NEW data flow library |
3534
| spread.js:4:15:4:22 | source() | spread.js:18:8:18:8 | y | only flow with NEW data flow library |
3635
| spread.js:4:15:4:22 | source() | spread.js:24:8:24:8 | y | only flow with NEW data flow library |
3736
| tst.js:2:13:2:20 | source() | tst.js:17:10:17:10 | a | only flow with OLD data flow library |
@@ -265,13 +264,15 @@ flow
265264
| sanitizer-guards.js:13:14:13:21 | source() | sanitizer-guards.js:26:9:26:14 | this.x |
266265
| sanitizer-guards.js:43:11:43:18 | source() | sanitizer-guards.js:45:8:45:8 | x |
267266
| sanitizer-guards.js:43:11:43:18 | source() | sanitizer-guards.js:48:10:48:10 | x |
268-
| sanitizer-guards.js:57:11:57:18 | source() | sanitizer-guards.js:64:8:64:8 | x |
269267
| sanitizer-guards.js:68:11:68:18 | source() | sanitizer-guards.js:75:8:75:8 | x |
270-
| sanitizer-guards.js:79:11:79:18 | source() | sanitizer-guards.js:81:8:81:8 | x |
271-
| sanitizer-guards.js:79:11:79:18 | source() | sanitizer-guards.js:84:10:84:10 | x |
272-
| sanitizer-guards.js:91:11:91:18 | source() | sanitizer-guards.js:93:8:93:8 | x |
273-
| sanitizer-guards.js:91:11:91:18 | source() | sanitizer-guards.js:98:7:98:7 | x |
274-
| sanitizer-guards.js:91:11:91:18 | source() | sanitizer-guards.js:104:7:104:7 | x |
268+
| sanitizer-guards.js:102:9:102:16 | source() | sanitizer-guards.js:104:8:104:8 | x |
269+
| sanitizer-guards.js:108:11:108:18 | source() | sanitizer-guards.js:112:8:112:8 | x |
270+
| sanitizer-guards.js:116:11:116:18 | source() | sanitizer-guards.js:121:8:121:8 | x |
271+
| sanitizer-guards.js:135:11:135:18 | source() | sanitizer-guards.js:137:8:137:8 | x |
272+
| sanitizer-guards.js:135:11:135:18 | source() | sanitizer-guards.js:140:10:140:10 | x |
273+
| sanitizer-guards.js:147:11:147:18 | source() | sanitizer-guards.js:149:8:149:8 | x |
274+
| sanitizer-guards.js:147:11:147:18 | source() | sanitizer-guards.js:154:7:154:7 | x |
275+
| sanitizer-guards.js:147:11:147:18 | source() | sanitizer-guards.js:160:7:160:7 | x |
275276
| spread.js:4:15:4:22 | source() | spread.js:6:8:6:19 | { ...taint } |
276277
| spread.js:4:15:4:22 | source() | spread.js:7:8:7:43 | { f: 'h ... orld' } |
277278
| spread.js:4:15:4:22 | source() | spread.js:9:8:9:19 | [ ...taint ] |

‎javascript/ql/test/library-tests/TaintTracking/DataFlowTracking.expected‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ legacyDataFlowDifference
2222
| nested-props.js:14:15:14:22 | source() | nested-props.js:15:10:15:16 | obj.x.y | only flow with NEW data flow library |
2323
| nested-props.js:27:18:27:25 | source() | nested-props.js:28:10:28:14 | obj.x | only flow with NEW data flow library |
2424
| nested-props.js:51:22:51:29 | source() | nested-props.js:52:10:52:16 | obj.x.y | only flow with NEW data flow library |
25-
| sanitizer-guards.js:57:11:57:18 | source() | sanitizer-guards.js:64:8:64:8 | x | only flow with NEW data flow library |
2625
| spread.js:4:15:4:22 | source() | spread.js:18:8:18:8 | y | only flow with NEW data flow library |
2726
| spread.js:4:15:4:22 | source() | spread.js:24:8:24:8 | y | only flow with NEW data flow library |
2827
| tst.js:2:13:2:20 | source() | tst.js:35:14:35:16 | ary | only flow with NEW data flow library |
@@ -174,16 +173,18 @@ flow
174173
| sanitizer-guards.js:43:11:43:18 | source() | sanitizer-guards.js:45:8:45:8 | x |
175174
| sanitizer-guards.js:43:11:43:18 | source() | sanitizer-guards.js:48:10:48:10 | x |
176175
| sanitizer-guards.js:43:11:43:18 | source() | sanitizer-guards.js:52:10:52:10 | x |
177-
| sanitizer-guards.js:57:11:57:18 | source() | sanitizer-guards.js:64:8:64:8 | x |
178176
| sanitizer-guards.js:68:11:68:18 | source() | sanitizer-guards.js:75:8:75:8 | x |
179-
| sanitizer-guards.js:79:11:79:18 | source() | sanitizer-guards.js:81:8:81:8 | x |
180-
| sanitizer-guards.js:79:11:79:18 | source() | sanitizer-guards.js:84:10:84:10 | x |
181-
| sanitizer-guards.js:79:11:79:18 | source() | sanitizer-guards.js:86:9:86:9 | x |
182-
| sanitizer-guards.js:91:11:91:18 | source() | sanitizer-guards.js:93:8:93:8 | x |
183-
| sanitizer-guards.js:91:11:91:18 | source() | sanitizer-guards.js:96:10:96:10 | x |
184-
| sanitizer-guards.js:91:11:91:18 | source() | sanitizer-guards.js:98:7:98:7 | x |
185-
| sanitizer-guards.js:91:11:91:18 | source() | sanitizer-guards.js:102:10:102:10 | x |
186-
| sanitizer-guards.js:91:11:91:18 | source() | sanitizer-guards.js:104:7:104:7 | x |
177+
| sanitizer-guards.js:102:9:102:16 | source() | sanitizer-guards.js:104:8:104:8 | x |
178+
| sanitizer-guards.js:108:11:108:18 | source() | sanitizer-guards.js:112:8:112:8 | x |
179+
| sanitizer-guards.js:116:11:116:18 | source() | sanitizer-guards.js:121:8:121:8 | x |
180+
| sanitizer-guards.js:135:11:135:18 | source() | sanitizer-guards.js:137:8:137:8 | x |
181+
| sanitizer-guards.js:135:11:135:18 | source() | sanitizer-guards.js:140:10:140:10 | x |
182+
| sanitizer-guards.js:135:11:135:18 | source() | sanitizer-guards.js:142:9:142:9 | x |
183+
| sanitizer-guards.js:147:11:147:18 | source() | sanitizer-guards.js:149:8:149:8 | x |
184+
| sanitizer-guards.js:147:11:147:18 | source() | sanitizer-guards.js:152:10:152:10 | x |
185+
| sanitizer-guards.js:147:11:147:18 | source() | sanitizer-guards.js:154:7:154:7 | x |
186+
| sanitizer-guards.js:147:11:147:18 | source() | sanitizer-guards.js:158:10:158:10 | x |
187+
| sanitizer-guards.js:147:11:147:18 | source() | sanitizer-guards.js:160:7:160:7 | x |
187188
| spread.js:4:15:4:22 | source() | spread.js:18:8:18:8 | y |
188189
| spread.js:4:15:4:22 | source() | spread.js:24:8:24:8 | y |
189190
| thisAssignments.js:4:17:4:24 | source() | thisAssignments.js:5:10:5:18 | obj.field |

‎javascript/ql/test/library-tests/TaintTracking/sanitizer-guards.js‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ function phi() {
6161
} else {
6262
x = null;
6363
}
64-
sink(x); // OK [INCONSISTENCY] - dataflow2 cannot block the phi edge
64+
sink(x); // OK
6565
}
6666

6767
function phi2() {
@@ -75,6 +75,62 @@ function phi2() {
7575
sink(x); // NOT OK
7676
}
7777

78+
function fallbackAssignment() {
79+
let x = source();
80+
81+
if (!isSafe(x)) {
82+
x = "safe";
83+
}
84+
sink(x); // OK
85+
}
86+
87+
function fallbackAssignmentElse() {
88+
let x = source();
89+
90+
if (isSafe(x)) {
91+
// The checked value is safe.
92+
} else {
93+
x = "safe";
94+
}
95+
sink(x); // OK
96+
}
97+
98+
function fallbackUnsafe() {
99+
let x = source();
100+
101+
if (!isSafe(x)) {
102+
x = source();
103+
}
104+
sink(x); // NOT OK: the fallback is tainted.
105+
}
106+
107+
function fallbackWrongPolarity() {
108+
let x = source();
109+
if (isSafe(x)) {
110+
x = "safe";
111+
}
112+
sink(x); // NOT OK - the fall-through edge is unchecked
113+
}
114+
115+
function fallbackGuardOnOtherVariable() {
116+
let x = source();
117+
let y = source();
118+
if (!isSafe(y)) {
119+
x = "safe";
120+
}
121+
sink(x); // NOT OK - the guard checks y, not x
122+
}
123+
124+
function fallbackLoopInsideGuard() {
125+
let x = source();
126+
if (isSafe(x)) {
127+
while (cond()) {
128+
x = "safe";
129+
}
130+
sink(x); // OK
131+
}
132+
}
133+
78134
function falsy() {
79135
let x = source();
80136

‎javascript/ql/test/query-tests/Security/CWE-078/UnsafeShellCommandConstruction/UnsafeShellCommandConstruction.expected‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747
| lib/lib.js:228:10:228:25 | "rm -rf " + name | lib/lib.js:227:39:227:42 | name | lib/lib.js:228:22:228:25 | name | This string concatenation which depends on $@ is later used in a $@. | lib/lib.js:227:39:227:42 | name | library input | lib/lib.js:228:2:228:26 | cp.exec ... + name) | shell command |
4848
| lib/lib.js:236:10:236:25 | "rm -rf " + name | lib/lib.js:227:39:227:42 | name | lib/lib.js:236:22:236:25 | name | This string concatenation which depends on $@ is later used in a $@. | lib/lib.js:227:39:227:42 | name | library input | lib/lib.js:236:2:236:26 | cp.exec ... + name) | shell command |
4949
| lib/lib.js:249:10:249:25 | "rm -rf " + name | lib/lib.js:248:42:248:45 | name | lib/lib.js:249:22:249:25 | name | This string concatenation which depends on $@ is later used in a $@. | lib/lib.js:248:42:248:45 | name | library input | lib/lib.js:249:2:249:26 | cp.exec ... + name) | shell command |
50-
| lib/lib.js:253:10:253:28 | "rm -rf " + cleaned | lib/lib.js:248:42:248:45 | name | lib/lib.js:253:22:253:28 | cleaned | This string concatenation which depends on $@ is later used in a $@. | lib/lib.js:248:42:248:45 | name | library input | lib/lib.js:253:2:253:29 | cp.exec ... leaned) | shell command |
5150
| lib/lib.js:258:10:258:25 | "rm -rf " + name | lib/lib.js:257:35:257:38 | name | lib/lib.js:258:22:258:25 | name | This string concatenation which depends on $@ is later used in a $@. | lib/lib.js:257:35:257:38 | name | library input | lib/lib.js:258:2:258:26 | cp.exec ... + name) | shell command |
5251
| lib/lib.js:261:11:261:33 | "rm -rf ... + name | lib/lib.js:257:35:257:38 | name | lib/lib.js:261:30:261:33 | name | This string concatenation which depends on $@ is later used in a $@. | lib/lib.js:257:35:257:38 | name | library input | lib/lib.js:261:3:261:34 | cp.exec ... + name) | shell command |
5352
| lib/lib.js:268:10:268:32 | "rm -rf ... version | lib/lib.js:267:46:267:48 | obj | lib/lib.js:268:22:268:32 | obj.version | This string concatenation which depends on $@ is later used in a $@. | lib/lib.js:267:46:267:48 | obj | library input | lib/lib.js:268:2:268:33 | cp.exec ... ersion) | shell command |
@@ -157,13 +156,7 @@ edges
157156
| lib/lib.js:216:39:216:42 | name | lib/lib.js:224:22:224:25 | name | provenance | |
158157
| lib/lib.js:227:39:227:42 | name | lib/lib.js:228:22:228:25 | name | provenance | |
159158
| lib/lib.js:227:39:227:42 | name | lib/lib.js:236:22:236:25 | name | provenance | |
160-
| lib/lib.js:239:28:239:28 | s | lib/lib.js:245:9:245:9 | s | provenance | |
161159
| lib/lib.js:248:42:248:45 | name | lib/lib.js:249:22:249:25 | name | provenance | |
162-
| lib/lib.js:248:42:248:45 | name | lib/lib.js:251:27:251:30 | name | provenance | |
163-
| lib/lib.js:251:6:251:12 | cleaned | lib/lib.js:253:22:253:28 | cleaned | provenance | |
164-
| lib/lib.js:251:16:251:31 | cleanInput(name) | lib/lib.js:251:6:251:12 | cleaned | provenance | |
165-
| lib/lib.js:251:27:251:30 | name | lib/lib.js:239:28:239:28 | s | provenance | |
166-
| lib/lib.js:251:27:251:30 | name | lib/lib.js:251:16:251:31 | cleanInput(name) | provenance | |
167160
| lib/lib.js:257:35:257:38 | name | lib/lib.js:258:22:258:25 | name | provenance | |
168161
| lib/lib.js:257:35:257:38 | name | lib/lib.js:261:30:261:33 | name | provenance | |
169162
| lib/lib.js:267:46:267:48 | obj | lib/lib.js:268:22:268:24 | obj | provenance | |
@@ -329,14 +322,8 @@ nodes
329322
| lib/lib.js:227:39:227:42 | name | semmle.label | name |
330323
| lib/lib.js:228:22:228:25 | name | semmle.label | name |
331324
| lib/lib.js:236:22:236:25 | name | semmle.label | name |
332-
| lib/lib.js:239:28:239:28 | s | semmle.label | s |
333-
| lib/lib.js:245:9:245:9 | s | semmle.label | s |
334325
| lib/lib.js:248:42:248:45 | name | semmle.label | name |
335326
| lib/lib.js:249:22:249:25 | name | semmle.label | name |
336-
| lib/lib.js:251:6:251:12 | cleaned | semmle.label | cleaned |
337-
| lib/lib.js:251:16:251:31 | cleanInput(name) | semmle.label | cleanInput(name) |
338-
| lib/lib.js:251:27:251:30 | name | semmle.label | name |
339-
| lib/lib.js:253:22:253:28 | cleaned | semmle.label | cleaned |
340327
| lib/lib.js:257:35:257:38 | name | semmle.label | name |
341328
| lib/lib.js:258:22:258:25 | name | semmle.label | name |
342329
| lib/lib.js:261:30:261:33 | name | semmle.label | name |
@@ -452,6 +439,5 @@ nodes
452439
| lib/subLib/index.js:13:44:13:46 | arr | semmle.label | arr |
453440
| lib/subLib/index.js:14:22:14:24 | arr | semmle.label | arr |
454441
subpaths
455-
| lib/lib.js:251:27:251:30 | name | lib/lib.js:239:28:239:28 | s | lib/lib.js:245:9:245:9 | s | lib/lib.js:251:16:251:31 | cleanInput(name) |
456442
| lib/lib.js:340:25:340:25 | n | lib/lib.js:329:13:329:13 | x | lib/lib.js:330:9:330:9 | x | lib/lib.js:340:22:340:26 | id(n) |
457443
| lib/lib.js:428:28:428:57 | (name ? ... ) + '-' | lib/lib.js:431:23:431:26 | last | lib/lib.js:437:9:437:11 | arr [ArrayElement] | lib/lib.js:428:14:428:58 | build(" ... + '-') |

0 commit comments

Comments
 (0)