Cover JavaScript SQL concat taint

ober

acca7295dbdbc9ab3d42e94a9ba9af971aa29335

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index 76007e6..5ac6e86 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -1,22 +1,22 @@
 # Opus 4.8 Handoff: jerboa-semgrep Semgrep Parity
 
-Date: 2026-05-30 06:21 MDT
+Date: 2026-05-30 07:02 MDT
 Workspace: `/Users/user/mine/jerboa-semgrep`
 Sibling upstream Semgrep checkout: `/Users/user/mine/semgrep`
 Packaged Semgrep oracle: `/Users/user/.local/bin/semgrep`
 Branch: `main`
 Base HEAD before this checkpoint:
-`8dd414c Refresh Opus parity handoff`
+`5abcf3d Scope JavaScript trailing inside matches`
 
 The user wants the pure Jerboa Semgrep port carried forward until it reaches
 Semgrep parity. Do not treat this handoff as completion. This checkpoint closes
-the upstream JavaScript taint `sanitized_by_side_effect` mismatch by bounding
-the JavaScript trailing-ellipsis `pattern-inside` fallback to the lexical block
-that contains the base match.
+the upstream JavaScript taint `await` mismatch by matching Semgrep's
+associative string-concat sink behavior for `"$SQLSTR" + $EXPR`, and it also
+fixes the direct concat finding in `simpl_nodejs_eval`.
 
 ## Immediate State
 
-The worktree was clean at `8dd414c` before this checkpoint. The implementation
+The worktree was clean at `5abcf3d` before this checkpoint. The implementation
 change is in `src/semgrep/scan.ss`; `lib/semgrep/scan.sls` and
 `src/.jerbuild-hashes` were regenerated by `make test`.
 
@@ -27,10 +27,10 @@ Current headline:
 - Promoted JavaScript pattern oracle: 91 passed / 0 mismatched.
 - Promoted Python pattern oracle: 116 passed / 0 mismatched.
 - Local oracle: 42 passed / 0 failed.
-- Smoke suite: 303 tests / 303 passed.
+- Smoke suite: 304 tests / 304 passed.
 - Broad same-basename upstream `tests/rules` sweep: 437 passed /
   0 mismatched / 0 Jerboa errors, with 3 packaged-Semgrep current errors.
-- Upstream `tests/tainting_rules/js` sweep: 7 passed / 4 mismatched /
+- Upstream `tests/tainting_rules/js` sweep: 8 passed / 3 mismatched /
   0 Jerboa errors / 0 current errors.
 
 Semgrep parity is not reached. The cleanest active frontier is JavaScript taint
@@ -100,7 +100,7 @@ Operational defaults:
 - Handoff-only checkpoint after the `taint_unify_mvars` fix.
 - Preserved the investigation notes for `sanitized_by_side_effect`.
 
-This checkpoint:
+`5abcf3d Scope JavaScript trailing inside matches`
 
 - Adds `javascript-matching-close-brace` and `javascript-enclosing-block-end`
   in `src/semgrep/scan.ss`.
@@ -111,6 +111,22 @@ This checkpoint:
 - Closes upstream JS tainting-rule case `sanitized_by_side_effect`.
 - JS tainting-rule sweep is now 7 passed / 4 mismatched.
 
+This checkpoint:
+
+- Adds a JavaScript fallback for quoted-string concat patterns shaped like
+  `"$SQLSTR" + $EXPR`, preserving `$SQLSTR` and `$EXPR` bindings.
+- Treats SQL concat matches as ending at the first non-literal operand after
+  literal prefixes, matching the upstream `await` range
+  `"SELECT ..." + " WHERE ..." + req.query.message`.
+- Allows a source contained inside a concat sink to reach that sink when the
+  sink text contains `+`; this is intentionally narrower than globally
+  allowing contained token sources.
+- Adds smoke coverage:
+  `scan JavaScript taint Express request reaches SQL string concat`.
+- Closes upstream JS tainting-rule case `await` and the missing direct concat
+  finding in `simpl_nodejs_eval`.
+- JS tainting-rule sweep is now 8 passed / 3 mismatched.
+
 This checkpoint modifies:
 
 ```text
@@ -123,6 +139,48 @@ tests/smoke.ss
 
 ## Latest Code Change Details
 
+### JavaScript SQL String Concat Taint
+
+The upstream `await` taint rule uses this sink:
+
+```yaml
+pattern-sinks:
+  - pattern: |
+      "$SQLSTR" + $EXPR
+```
+
+Semgrep matches the associative concat range through the first non-literal
+operand:
+
+```javascript
+"SELECT * FROM users" + " WHERE id = '" + req.query.message
+```
+
+Jerboa previously matched only the first binary fragment:
+
+```javascript
+"SELECT * FROM users" + " WHERE id = '"
+```
+
+That excluded `req.query.message`, so taint did not reach the sink. The scanner
+now has a JavaScript concat fallback for quoted-string metavariable patterns
+that:
+
+- recognizes `"$NAME" + $EXPR` / `'$NAME' + $EXPR` / `` `$NAME` + $EXPR ``;
+- keeps SQLSTR starts limited to strings containing common SQL verbs, avoiding
+  later literal fragments like `" WHERE ..."`;
+- binds `$EXPR` from after the first `+` through the first non-literal operand;
+- works in the binding-preserving formula path used by taint specs.
+
+Taint reachability also has a narrow contained-source branch for concat sinks:
+if the source occurrence is contained inside a sink whose text includes `+`,
+that source can reach the sink. This avoids the broader token-source change
+that overreported typestate cases.
+
+This closes `await` and also adds the missing direct concat report for
+`simpl_nodejs_eval`; that case still has a one-column template-literal range
+mismatch.
+
 ### Scoped Trailing-Ellipsis `pattern-inside`
 
 Semgrep treats a JavaScript `pattern-inside` such as:
@@ -232,7 +290,7 @@ make test
 Result:
 
 ```text
-303 tests, 303 passed, 0 failed
+304 tests, 304 passed, 0 failed
 ```
 
 Focused upstream JS taint `metavar_eq_simple`:
@@ -310,6 +368,21 @@ Result:
 upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
 ```
 
+Focused upstream JS taint `await`:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
+UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/js \
+CASE_REGEX='^(await)$' LIST_MISMATCHES=1 MAX_DIFFS=160 \
+tests/oracle/upstream-sweep.sh
+```
+
+Result:
+
+```text
+upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
+```
+
 Full JS tainting-rule subdirectory:
 
 ```sh
@@ -321,41 +394,34 @@ LIST_MISMATCHES=1 MAX_DIFFS=80 tests/oracle/upstream-sweep.sh
 Result:
 
 ```text
-upstream-sweep: 7 passed, 4 mismatched, 0 jerboa errors, 0 current errors, 11 compared
+upstream-sweep: 8 passed, 3 mismatched, 0 jerboa errors, 0 current errors, 11 compared
 ```
 
 ## Remaining JS Taint Mismatches
 
-The 4 remaining mismatches under `/Users/user/mine/semgrep/tests/tainting_rules/js`
+The 3 remaining mismatches under `/Users/user/mine/semgrep/tests/tainting_rules/js`
 are:
 
-1. `await`
-
-Semgrep reports two string-concat SQL sinks under Express-style arrow
-callbacks; Jerboa reports zero. Likely gap: `pattern-inside:
-function ... ($REQ, $RES) {...}` compatibility with arrow callback parameters,
-and/or taint through `await` plus string concatenation.
-
-2. `eslint_obj_inj`
+1. `eslint_obj_inj`
 
 Semgrep reports `return o[c]` after `c` is conditionally assigned from tainted
 parameter `x`; Jerboa misses the branch-merged taint. Likely gap:
 path-insensitive propagation from conditional assignments such as
 `if (...) c = x else c = 1`.
 
-3. `metavar_eq_conditional`
+2. `metavar_eq_conditional`
 
 Semgrep keeps both possible taint bindings from `cond ? get(A) : get(B)` and
 reports both `A` and `B` messages at both sinks. Jerboa keeps only the locally
 matching binding at each sink. Likely gap: conditional-expression sources need
 multiple alternative taint states/metavariable environments.
 
-4. `simpl_nodejs_eval`
+3. `simpl_nodejs_eval`
 
-Jerboa misses the direct concat sink
-`s.run('lol(' + req.query.userInput + ')', cb)` and has a one-column overlong
-range on the template-literal sink. Likely gaps: direct string concatenation
-taint into a focused sink expression, plus template literal range trimming.
+The direct concat sink is now fixed. The remaining mismatch is a one-column
+overlong range on the template-literal sink at line 26. Likely gap: template
+literal range trimming should exclude the closing backtick for this focused
+sink shape.
 
 ## Completed Target: `sanitized_by_side_effect`
 
@@ -624,18 +690,19 @@ Actual improvement:
 
 ## Recommended Next Target
 
-The next most tractable target is probably `await`:
+The next most tractable target is probably the remaining `simpl_nodejs_eval`
+template-literal range mismatch:
 
 ```sh
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
 UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/js \
-CASE_REGEX='^(await)$' LIST_MISMATCHES=1 MAX_DIFFS=120 \
+CASE_REGEX='^(simpl_nodejs_eval)$' LIST_MISMATCHES=1 MAX_DIFFS=160 \
 tests/oracle/upstream-sweep.sh
 ```
 
-Investigate whether a `pattern-inside: function ... ($REQ, $RES) {...}` source
-matches arrow callbacks like `(req, res) => { ... }`, and then check whether
-the string-concat sink is connected through `await`.
+The direct concat report at line 11 is now present, so focus only on the
+template-literal range at line 26. Semgrep ends at column 39 / offset 641;
+Jerboa ends at column 40 / offset 642, including one extra character.
 
 If touching conditional-source logic, keep `metavar_eq_simple` and
 `metavar_eq_conditional` separate in your head:
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index 0ec6578..47d70b7 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -20383,6 +20383,12 @@
                      source
                      (cdr entry)))
               (and (javascript-like-language? language)
+                   (scan-javascript-string-concat-pattern
+                     rule
+                     path
+                     source
+                     (cdr entry)))
+              (and (javascript-like-language? language)
                    (scan-javascript-template-sql-expression-pattern
                      rule
                      path
@@ -20660,6 +20666,8 @@
               (and (javascript-like-language? language)
                    (scan-javascript-member-ellipsis-plus-pattern-with-bindings rule path source (cdr entry) initial-bindings))
               (and (javascript-like-language? language)
+                   (scan-javascript-string-concat-pattern-with-bindings rule path source (cdr entry) initial-bindings))
+              (and (javascript-like-language? language)
                    (scan-javascript-template-sql-expression-pattern-with-bindings rule path source (cdr entry) initial-bindings))
               (and (javascript-like-language? language)
                    (scan-javascript-new-function-sequence-pattern
@@ -22811,6 +22819,213 @@
          source
          pattern)
        (scan-javascript-template-literal-pattern-with-bindings rule path source pattern '()))
+  (def (javascript-string-concat-pattern-spec pattern)
+       (let ([match (re-search
+                      (re "^[ \t\n]*[\"'`]\\$([A-Za-z_][A-Za-z0-9_]*)[\"'`][ \t\n]*\\+[ \t\n]*\\$([A-Za-z_][A-Za-z0-9_]*)[ \t\n]*$")
+                      pattern
+                      0)])
+         (and match
+              (list
+                (cons 'string (re-match-group match 1))
+                (cons 'expr (re-match-group match 2))))))
+  (def (javascript-sql-string-content? text)
+       (let ([lower (string-downcase text)])
+         (or (string-find-substring lower "select")
+             (string-find-substring lower "delete")
+             (string-find-substring lower "insert")
+             (string-find-substring lower "create")
+             (string-find-substring lower "update")
+             (string-find-substring lower "alter")
+             (string-find-substring lower "drop"))))
+  (def (javascript-string-concat-start-compatible? spec text)
+       (let ([name (alist-ref/default spec 'string "")])
+         (or (not (string=? name "SQLSTR"))
+             (javascript-sql-string-content? text))))
+  (def (javascript-concat-operand-end source start)
+       (let ([len (string-length source)])
+         (let loop ([i start]
+                    [paren-depth 0]
+                    [bracket-depth 0]
+                    [brace-depth 0]
+                    [state 'normal]
+                    [escaped? #f])
+           (cond
+             [(>= i len) (cdr (trim-range-horizontal source start len))]
+             [(eq? state 'normal)
+              (let ([ch (string-ref source i)])
+                (cond
+                  [(char=? ch #\")
+                   (loop (+ i 1) paren-depth bracket-depth brace-depth
+                     'double #f)]
+                  [(char=? ch #\')
+                   (loop (+ i 1) paren-depth bracket-depth brace-depth
+                     'single #f)]
+                  [(char=? ch #\`)
+                   (loop (+ i 1) paren-depth bracket-depth brace-depth
+                     'backtick #f)]
+                  [(and (< (+ i 1) len)
+                        (char=? ch #\/)
+                        (char=? (string-ref source (+ i 1)) #\/))
+                   (cdr (trim-range-horizontal source start i))]
+                  [(and (< (+ i 1) len)
+                        (char=? ch #\/)
+                        (char=? (string-ref source (+ i 1)) #\*))
+                   (loop (+ i 2) paren-depth bracket-depth brace-depth
+                     'block-comment #f)]
+                  [(char=? ch #\()
+                   (loop (+ i 1) (+ paren-depth 1) bracket-depth
+                     brace-depth state #f)]
+                  [(char=? ch #\[)
+                   (loop (+ i 1) paren-depth (+ bracket-depth 1)
+                     brace-depth state #f)]
+                  [(char=? ch #\{)
+                   (loop (+ i 1) paren-depth bracket-depth
+                     (+ brace-depth 1) state #f)]
+                  [(char=? ch #\))
+                   (if (and (= paren-depth 0)
+                            (= bracket-depth 0)
+                            (= brace-depth 0))
+                       (cdr (trim-range-horizontal source start i))
+                       (loop (+ i 1) (max 0 (- paren-depth 1))
+                         bracket-depth brace-depth state #f))]
+                  [(char=? ch #\])
+                   (if (and (= paren-depth 0)
+                            (= bracket-depth 0)
+                            (= brace-depth 0))
+                       (cdr (trim-range-horizontal source start i))
+                       (loop (+ i 1) paren-depth
+                         (max 0 (- bracket-depth 1)) brace-depth state
+                         #f))]
+                  [(char=? ch #\})
+                   (if (and (= paren-depth 0)
+                            (= bracket-depth 0)
+                            (= brace-depth 0))
+                       (cdr (trim-range-horizontal source start i))
+                       (loop (+ i 1) paren-depth bracket-depth
+                         (max 0 (- brace-depth 1)) state #f))]
+                  [(and (= paren-depth 0)
+                        (= bracket-depth 0)
+                        (= brace-depth 0)
+                        (or (char=? ch #\+)
+                            (char=? ch #\,)
+                            (char=? ch #\;)
+                            (char=? ch #\newline)))
+                   (cdr (trim-range-horizontal source start i))]
+                  [else
+                   (loop (+ i 1) paren-depth bracket-depth brace-depth
+                     state #f)]))]
+             [(eq? state 'block-comment)
+              (if (and (< (+ i 1) len)
+                       (char=? (string-ref source i) #\*)
+                       (char=? (string-ref source (+ i 1)) #\/))
+                  (loop (+ i 2) paren-depth bracket-depth brace-depth
+                    'normal #f)
+                  (loop (+ i 1) paren-depth bracket-depth brace-depth state
+                    #f))]
+             [escaped?
+              (loop (+ i 1) paren-depth bracket-depth brace-depth state
+                #f)]
+             [(char=? (string-ref source i) #\\)
+              (loop (+ i 1) paren-depth bracket-depth brace-depth state
+                #t)]
+             [(and (eq? state 'double)
+                   (char=? (string-ref source i) #\"))
+              (loop (+ i 1) paren-depth bracket-depth brace-depth 'normal
+                #f)]
+             [(and (eq? state 'single)
+                   (char=? (string-ref source i) #\'))
+              (loop (+ i 1) paren-depth bracket-depth brace-depth 'normal
+                #f)]
+             [(and (eq? state 'backtick)
+                   (char=? (string-ref source i) #\`))
+              (loop (+ i 1) paren-depth bracket-depth brace-depth 'normal
+                #f)]
+             [else
+              (loop (+ i 1) paren-depth bracket-depth brace-depth state
+                #f)]))))
+  (def (javascript-string-concat-chain-end source start)
+       (let ([first (parse-code-string-literal-at source start)])
+         (and first
+              (let loop ([operand-end (cdr first)] [saw-plus? #f])
+                (let ([after (skip-whitespace source operand-end)])
+                  (if (and (< after (string-length source))
+                           (char=? (string-ref source after) #\+))
+                      (let* ([next-start (skip-whitespace
+                                           source
+                                           (+ after 1))]
+                             [literal? (parse-code-string-literal-at
+                                         source
+                                         next-start)]
+                             [next-end (javascript-concat-operand-end
+                                         source
+                                         next-start)])
+                        (and (> next-end next-start)
+                             (if literal? (loop next-end #t) next-end)))
+                      (and saw-plus? operand-end)))))))
+  (def (javascript-string-concat-finding rule path source spec start first-literal chain-end
+         initial-bindings)
+       (let* ([string-name (alist-ref/default spec 'string "")]
+              [expr-name (alist-ref/default spec 'expr "")]
+              [first-end (cdr first-literal)]
+              [after-first (skip-whitespace source first-end)]
+              [expr-start (skip-whitespace source (+ after-first 1))]
+              [content-start (+ start 1)]
+              [content-end (- first-end 1)]
+              [content (car first-literal)]
+              [string-binding (and (javascript-string-concat-start-compatible?
+                                     spec
+                                     content)
+                                   (make-regex-capture-binding string-name content source
+                                     content-start content-end))]
+              [expr-binding (and string-binding
+                                 (< expr-start chain-end)
+                                 (make-regex-capture-binding expr-name
+                                   (substring source expr-start chain-end)
+                                   source expr-start chain-end))]
+              [merged (and expr-binding
+                           (merge-binding-list
+                             initial-bindings
+                             (list
+                               (cons string-name string-binding)
+                               (cons expr-name expr-binding))))])
+         (and merged
+              (finding-for-range-with-bindings rule path source start
+                chain-end merged))))
+  (def (scan-javascript-string-concat-pattern-with-bindings rule path source pattern initial-bindings)
+       (let ([spec (javascript-string-concat-pattern-spec
+                     pattern)])
+         (and spec
+              (let ([len (string-length source)])
+                (let loop ([offset 0] [acc '()])
+                  (if (>= offset len)
+                      (nonempty-findings (reverse acc))
+                      (let ([ch (string-ref source offset)])
+                        (if (code-string-quote? ch)
+                            (let* ([first (parse-code-string-literal-at
+                                            source
+                                            offset)]
+                                   [chain-end (and first
+                                                   (javascript-string-concat-chain-end
+                                                     source
+                                                     offset))]
+                                   [finding (and first
+                                                 chain-end
+                                                 (javascript-string-concat-finding rule path source spec
+                                                   offset first chain-end
+                                                   initial-bindings))]
+                                   [next (if first
+                                             (max (+ offset 1) (cdr first))
+                                             (+ offset 1))])
+                              (loop
+                                next
+                                (if finding (cons finding acc) acc)))
+                            (loop (+ offset 1) acc)))))))))
+  (def (scan-javascript-string-concat-pattern
+         rule
+         path
+         source
+         pattern)
+       (scan-javascript-string-concat-pattern-with-bindings rule path source pattern '()))
   (def (javascript-template-sql-expression-pattern? pattern)
        (and (string-find-substring pattern "`")
             (string-find-substring pattern "$SQLSTR")
@@ -31435,6 +31650,11 @@
                   (source-state-before-sink? source-state source sink)
                   (and (taint-state-contained? source-state)
                        (finding-range-contains? sink source))
+                  (and (finding-range-contains? sink source)
+                       (not (finding-range-equal? sink source))
+                       (string-find-substring
+                         (finding-text sink source-text)
+                         "+"))
                   (and (not (taint-state-token? source-state))
                        (finding-range-contains? sink source)
                        (source-access-path-compatible-with-sink?
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index d26eec3..01bf366 100644
--- a/src/.jerbuild-hashes
+++ b/src/.jerbuild-hashes
@@ -3,11 +3,11 @@
   ("src/semgrep/output/json.ss" . "293881CFA2ADB7BC")
   ("src/semgrep/lang.ss" . "6982E07679D20836")
   ("src/semgrep/parse/parse-target.ss" . "E74854DDDACF6BA")
-  ("src/semgrep/scan.ss" . "26462685AB67B945")
+  ("src/semgrep/scan.ss" . "16C6606401DEC6D9")
+  ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
+  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
   ("src/semgrep/rule.ss" . "E12C108153C181FA")
   ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0")
-  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
-  ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
   ("src/semgrep/match/structural.ss" . "F7B63A9A6FA028B")
   ("src/semgrep/main.ss" . "A4EC9E7F2A09D25E")
   ("src/semgrep/cli.ss" . "EBDC4B1DAD3F13CC"))
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index 7287b6e..17fccfd 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -20247,6 +20247,12 @@
                 source
                 (cdr entry)))
          (and (javascript-like-language? language)
+              (scan-javascript-string-concat-pattern
+                rule
+                path
+                source
+                (cdr entry)))
+         (and (javascript-like-language? language)
               (scan-javascript-template-sql-expression-pattern
                 rule
                 path
@@ -20593,6 +20599,13 @@
                 (cdr entry)
                 initial-bindings))
          (and (javascript-like-language? language)
+              (scan-javascript-string-concat-pattern-with-bindings
+                rule
+                path
+                source
+                (cdr entry)
+                initial-bindings))
+         (and (javascript-like-language? language)
               (scan-javascript-template-sql-expression-pattern-with-bindings
                 rule
                 path
@@ -22897,6 +22910,244 @@
     pattern
     '()))
 
+(def (javascript-string-concat-pattern-spec pattern)
+  (let ([match
+         (re-search
+           (re "^[ \t\n]*[\"'`]\\$([A-Za-z_][A-Za-z0-9_]*)[\"'`][ \t\n]*\\+[ \t\n]*\\$([A-Za-z_][A-Za-z0-9_]*)[ \t\n]*$")
+           pattern
+           0)])
+    (and match
+         (list (cons 'string (re-match-group match 1))
+               (cons 'expr (re-match-group match 2))))))
+
+(def (javascript-sql-string-content? text)
+  (let ([lower (string-downcase text)])
+    (or (string-find-substring lower "select")
+        (string-find-substring lower "delete")
+        (string-find-substring lower "insert")
+        (string-find-substring lower "create")
+        (string-find-substring lower "update")
+        (string-find-substring lower "alter")
+        (string-find-substring lower "drop"))))
+
+(def (javascript-string-concat-start-compatible? spec text)
+  (let ([name (alist-ref/default spec 'string "")])
+    (or (not (string=? name "SQLSTR"))
+        (javascript-sql-string-content? text))))
+
+(def (javascript-concat-operand-end source start)
+  (let ([len (string-length source)])
+    (let loop ([i start]
+               [paren-depth 0]
+               [bracket-depth 0]
+               [brace-depth 0]
+               [state 'normal]
+               [escaped? #f])
+      (cond
+        [(>= i len)
+         (cdr (trim-range-horizontal source start len))]
+        [(eq? state 'normal)
+         (let ([ch (string-ref source i)])
+           (cond
+             [(char=? ch #\")
+              (loop (+ i 1) paren-depth bracket-depth brace-depth 'double #f)]
+             [(char=? ch #\')
+              (loop (+ i 1) paren-depth bracket-depth brace-depth 'single #f)]
+             [(char=? ch #\`)
+              (loop (+ i 1) paren-depth bracket-depth brace-depth 'backtick #f)]
+             [(and (< (+ i 1) len)
+                   (char=? ch #\/)
+                   (char=? (string-ref source (+ i 1)) #\/))
+              (cdr (trim-range-horizontal source start i))]
+             [(and (< (+ i 1) len)
+                   (char=? ch #\/)
+                   (char=? (string-ref source (+ i 1)) #\*))
+              (loop (+ i 2) paren-depth bracket-depth brace-depth 'block-comment #f)]
+             [(char=? ch #\()
+              (loop (+ i 1) (+ paren-depth 1) bracket-depth brace-depth state #f)]
+             [(char=? ch #\[)
+              (loop (+ i 1) paren-depth (+ bracket-depth 1) brace-depth state #f)]
+             [(char=? ch #\{)
+              (loop (+ i 1) paren-depth bracket-depth (+ brace-depth 1) state #f)]
+             [(char=? ch #\))
+              (if (and (= paren-depth 0)
+                       (= bracket-depth 0)
+                       (= brace-depth 0))
+                  (cdr (trim-range-horizontal source start i))
+                  (loop (+ i 1)
+                        (max 0 (- paren-depth 1))
+                        bracket-depth
+                        brace-depth
+                        state
+                        #f))]
+             [(char=? ch #\])
+              (if (and (= paren-depth 0)
+                       (= bracket-depth 0)
+                       (= brace-depth 0))
+                  (cdr (trim-range-horizontal source start i))
+                  (loop (+ i 1)
+                        paren-depth
+                        (max 0 (- bracket-depth 1))
+                        brace-depth
+                        state
+                        #f))]
+             [(char=? ch #\})
+              (if (and (= paren-depth 0)
+                       (= bracket-depth 0)
+                       (= brace-depth 0))
+                  (cdr (trim-range-horizontal source start i))
+                  (loop (+ i 1)
+                        paren-depth
+                        bracket-depth
+                        (max 0 (- brace-depth 1))
+                        state
+                        #f))]
+             [(and (= paren-depth 0)
+                   (= bracket-depth 0)
+                   (= brace-depth 0)
+                   (or (char=? ch #\+)
+                       (char=? ch #\,)
+                       (char=? ch #\;)
+                       (char=? ch #\newline)))
+              (cdr (trim-range-horizontal source start i))]
+             [else
+              (loop (+ i 1) paren-depth bracket-depth brace-depth state #f)]))]
+        [(eq? state 'block-comment)
+         (if (and (< (+ i 1) len)
+                  (char=? (string-ref source i) #\*)
+                  (char=? (string-ref source (+ i 1)) #\/))
+             (loop (+ i 2) paren-depth bracket-depth brace-depth 'normal #f)
+             (loop (+ i 1) paren-depth bracket-depth brace-depth state #f))]
+        [escaped?
+         (loop (+ i 1) paren-depth bracket-depth brace-depth state #f)]
+        [(char=? (string-ref source i) #\\)
+         (loop (+ i 1) paren-depth bracket-depth brace-depth state #t)]
+        [(and (eq? state 'double) (char=? (string-ref source i) #\"))
+         (loop (+ i 1) paren-depth bracket-depth brace-depth 'normal #f)]
+        [(and (eq? state 'single) (char=? (string-ref source i) #\'))
+         (loop (+ i 1) paren-depth bracket-depth brace-depth 'normal #f)]
+        [(and (eq? state 'backtick) (char=? (string-ref source i) #\`))
+         (loop (+ i 1) paren-depth bracket-depth brace-depth 'normal #f)]
+        [else
+         (loop (+ i 1) paren-depth bracket-depth brace-depth state #f)]))))
+
+(def (javascript-string-concat-chain-end source start)
+  (let ([first (parse-code-string-literal-at source start)])
+    (and first
+         (let loop ([operand-end (cdr first)] [saw-plus? #f])
+           (let ([after (skip-whitespace source operand-end)])
+             (if (and (< after (string-length source))
+                      (char=? (string-ref source after) #\+))
+                 (let* ([next-start (skip-whitespace source (+ after 1))]
+                        [literal? (parse-code-string-literal-at
+                                    source
+                                    next-start)]
+                        [next-end
+                         (javascript-concat-operand-end
+                           source
+                           next-start)])
+                   (and (> next-end next-start)
+                        (if literal?
+                            (loop next-end #t)
+                            next-end)))
+                 (and saw-plus? operand-end)))))))
+
+(def (javascript-string-concat-finding
+       rule
+       path
+       source
+       spec
+       start
+       first-literal
+       chain-end
+       initial-bindings)
+  (let* ([string-name (alist-ref/default spec 'string "")]
+         [expr-name (alist-ref/default spec 'expr "")]
+         [first-end (cdr first-literal)]
+         [after-first (skip-whitespace source first-end)]
+         [expr-start (skip-whitespace source (+ after-first 1))]
+         [content-start (+ start 1)]
+         [content-end (- first-end 1)]
+         [content (car first-literal)]
+         [string-binding
+          (and (javascript-string-concat-start-compatible? spec content)
+               (make-regex-capture-binding
+                 string-name
+                 content
+                 source
+                 content-start
+                 content-end))]
+         [expr-binding
+          (and string-binding
+               (< expr-start chain-end)
+               (make-regex-capture-binding
+                 expr-name
+                 (substring source expr-start chain-end)
+                 source
+                 expr-start
+                 chain-end))]
+         [merged
+          (and expr-binding
+               (merge-binding-list
+                 initial-bindings
+                 (list (cons string-name string-binding)
+                       (cons expr-name expr-binding))))])
+    (and merged
+         (finding-for-range-with-bindings
+           rule
+           path
+           source
+           start
+           chain-end
+           merged))))
+
+(def (scan-javascript-string-concat-pattern-with-bindings
+       rule
+       path
+       source
+       pattern
+       initial-bindings)
+  (let ([spec (javascript-string-concat-pattern-spec pattern)])
+    (and spec
+         (let ([len (string-length source)])
+           (let loop ([offset 0] [acc '()])
+             (if (>= offset len)
+                 (nonempty-findings (reverse acc))
+                 (let ([ch (string-ref source offset)])
+                   (if (code-string-quote? ch)
+                       (let* ([first
+                               (parse-code-string-literal-at source offset)]
+                              [chain-end
+                               (and first
+                                    (javascript-string-concat-chain-end
+                                      source
+                                      offset))]
+                              [finding
+                               (and first
+                                    chain-end
+                                    (javascript-string-concat-finding
+                                      rule
+                                      path
+                                      source
+                                      spec
+                                      offset
+                                      first
+                                      chain-end
+                                      initial-bindings))]
+                              [next (if first
+                                        (max (+ offset 1) (cdr first))
+                                        (+ offset 1))])
+                         (loop next (if finding (cons finding acc) acc)))
+                       (loop (+ offset 1) acc)))))))))
+
+(def (scan-javascript-string-concat-pattern rule path source pattern)
+  (scan-javascript-string-concat-pattern-with-bindings
+    rule
+    path
+    source
+    pattern
+    '()))
+
 (def (javascript-template-sql-expression-pattern? pattern)
   (and (string-find-substring pattern "`")
        (string-find-substring pattern "$SQLSTR")
@@ -31342,6 +31593,11 @@
              (source-state-before-sink? source-state source sink)
              (and (taint-state-contained? source-state)
                   (finding-range-contains? sink source))
+             (and (finding-range-contains? sink source)
+                  (not (finding-range-equal? sink source))
+                  (string-find-substring
+                    (finding-text sink source-text)
+                    "+"))
              (and (not (taint-state-token? source-state))
                   (finding-range-contains? sink source)
                   (source-access-path-compatible-with-sink?
diff --git a/tests/smoke.ss b/tests/smoke.ss
index aa85bbe..ae1def1 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -4201,6 +4201,19 @@
     (check (map finding-start-line findings) => '(5 14))
     (check (map finding-start-col findings) => '(31 31))))
 
+(test-case "scan JavaScript taint Express request reaches SQL string concat"
+  (let* ([taint-config
+          "rules:\n  - id: demo.taint.js.express.sql.concat\n    mode: taint\n    languages: [javascript]\n    message: sql concat taint\n    severity: WARNING\n    pattern-sources:\n      - patterns:\n          - pattern-either:\n              - pattern-inside: function ... ($REQ, $RES) {...}\n              - pattern-inside: function ... ($REQ, $RES, $NEXT) {...}\n          - pattern: $REQ\n    pattern-sinks:\n      - pattern: |\n          \"$SQLSTR\" + $EXPR\n"]
+         [findings
+          (scan-config-string
+            taint-config
+            "javascript"
+            "demo.js"
+            "app.get('/test', (req, res) => {\n  res.send(\"SELECT * FROM users\" + \" WHERE id = '\" + req.query.message + \"'\")\n})\n\napp.get('/test1', (req, res) => {\n  await res.send(\"SELECT * FROM users\" + \" WHERE id = '\" + req.query.message + \"'\")\n})\n")])
+    (check (length findings) => 2)
+    (check (map finding-start-line findings) => '(2 6))
+    (check (map finding-end-col findings) => '(71 77))))
+
 (test-case "scan TypeScript taint typed hostname into request options"
   (let* ([taint-config
           "rules:\n  - id: demo.taint.ts.request.host\n    mode: taint\n    languages: [ts]\n    message: request host taint\n    severity: WARNING\n    pattern-sources:\n      - pattern: httpOptions.hostname\n    pattern-sinks:\n      - patterns:\n          - pattern: |\n              (...).request({...})\n          - pattern-not:\n              pattern: |\n                (...).request({\n                  beforeRedirect: ($OPTS) => {\n                    ...\n                    sanitizeIP($OPTS.hostname)\n                    ...\n                  },\n                  ...,\n                  lookup: getLookupFunction(..., sanitize)\n                })\n    options:\n      symbolic_propagation: true\n"]