Cover JavaScript SQL concat taint
ober
acca7295dbdbc9ab3d42e94a9ba9af971aa29335
--- 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: --- 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? --- 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")) --- 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? --- 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"]