Respect Ruby case branch taint paths
ober
633e0f1af904badd7f58117fa1119f9c405aff46
--- a/HANDOFF_OPUS_4_8.md +++ b/HANDOFF_OPUS_4_8.md @@ -1,27 +1,26 @@ # Opus 4.8 Handoff: jerboa-semgrep Semgrep Parity -Date: 2026-05-30 11:21 MDT +Date: 2026-05-30 11:50 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: -`422c66f Filter unreachable Python taint tails` +`482f971 Honor Django safe URL taint guards` The user wants the pure Jerboa Semgrep port carried forward until it reaches Semgrep parity. Do not treat this handoff as completion. The latest checkpoint -closes the final current upstream Python tainting-rule mismatch, -`simpl_django_redirect`, by suppressing Django redirect taint sources in -functions guarded by imported `is_safe_url(...)` checks. +closes the current upstream Ruby tainting-rule mismatch, `switch`, by making +sanitizer blocking and assignment kills respect sibling Ruby `case` branches. ## Immediate State -The worktree was clean at `422c66f` before this checkpoint. The latest +The worktree was clean at `482f971` before this checkpoint. The latest implementation change is in [src/semgrep/scan.ss](/Users/user/mine/jerboa-semgrep/src/semgrep/scan.ss); [lib/semgrep/scan.sls](/Users/user/mine/jerboa-semgrep/lib/semgrep/scan.sls) and [src/.jerbuild-hashes](/Users/user/mine/jerboa-semgrep/src/.jerbuild-hashes) -were regenerated by `make test`. Focused Django taint source guard smoke +were regenerated by `make test`. Focused Ruby `case` branch taint smoke coverage was added in [tests/smoke.ss](/Users/user/mine/jerboa-semgrep/tests/smoke.ss). Current headline: @@ -31,18 +30,20 @@ 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: 311 tests / 311 passed. +- Smoke suite: 312 tests / 312 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: 11 passed / 0 mismatched / 0 Jerboa errors / 0 current errors. - Upstream `tests/tainting_rules/python` sweep: 12 passed / 0 mismatched / 0 Jerboa errors / 0 current errors. +- Upstream `tests/tainting_rules/ruby` sweep: 1 passed / 0 mismatched / + 0 Jerboa errors / 0 current errors. -Semgrep parity is not reached. The JavaScript tainting-rule subdirectory is -clean; the active frontier is now semantic taint-control-flow parity in other -upstream `tests/tainting_rules` subdirectories, especially Python, Go, PHP, -Dart, and Ruby. +Semgrep parity is not reached. The JavaScript, Python, and Ruby +`tests/tainting_rules` subdirectories are clean against the packaged oracle. +The active frontier is now semantic taint-control-flow parity in the remaining +upstream `tests/tainting_rules` subdirectories, especially Dart, Go, and PHP. ## Repository Context @@ -181,6 +182,22 @@ This checkpoint: Latest checkpoint: +- Adds Ruby `case`/`when`/`else` sibling-branch detection for taint + reachability. +- Prevents a sanitizer or clean assignment in one Ruby `when` branch from + killing a tainted value that reaches a sink in a sibling `when` or `else` + branch. +- Extends sanitizer blocking, assignment kills, and sanitizer-blocked + propagators to check the sanitizer/assignment against the sink or propagator + site, not only source against assignment. +- Closes upstream Ruby tainting-rule case `switch`. +- Moves the Ruby tainting-rule sweep from `0 passed / 1 mismatched / + 0 Jerboa errors` to `1 passed / 0 mismatched / 0 Jerboa errors`. +- Adds smoke coverage: + `scan Ruby taint keeps sibling case branches separate`. + +Previous checkpoint: + - Adds a Python/Django taint source filter for rules whose source formula contains `is_safe_url` and whose sinks are Django redirects. - Removes source matches inside functions containing an imported @@ -227,6 +244,108 @@ tests/smoke.ss ## Latest Code Change Details +### Ruby Case Branch Taint Reachability + +The upstream Ruby taint case `switch` is: + +```ruby +def f() + i = source() + sink(i) # report + case x + when 0 + i = sanitize(i) + sink(i) # no report + when 1 + sink(i) # report + else + sink(i) # report + end +end +``` + +The rule is the normal taint shape: + +```yaml +pattern-sources: + - pattern: source(...) +pattern-sanitizers: + - pattern: sanitize(...) +pattern-sinks: + - pattern: sink(...) +``` + +Jerboa previously reported only the first sink. The `i = sanitize(i)` finding +inside `when 0` was path-insensitively treated as both: + +- a sanitizer between the original propagated `i` source and later sinks, and +- a clean assignment kill for the implicit assignment source state carried by + `i`. + +That was correct for the sink in the same branch, but wrong for sibling +branches. Semgrep treats Ruby `when`/`else` clauses in the same `case` as +mutually exclusive, so the sanitizer in `when 0` cannot clean the paths through +`when 1` or `else`. + +The patch adds these line-oriented helpers in +[src/semgrep/scan.ss](/Users/user/mine/jerboa-semgrep/src/semgrep/scan.ss): + +- `ruby-significant-trimmed-line` +- `ruby-keyword-line?` +- `ruby-case-line?` +- `ruby-case-clause-kind` +- `ruby-parent-case-clause` +- `ruby-case-chain-start` +- `ruby-case-branch-info` +- `ruby-findings-in-mutually-exclusive-case-branches?` + +`findings-in-mutually-exclusive-if-branches?` now also delegates to the Ruby +case helper. The name is now a little narrow, but it preserves the existing call +sites and keeps this checkpoint small. + +Three taint reachability sites now avoid cross-branch suppression: + +- `sanitizer-blocks?` checks whether the sanitizer and sink are in sibling + mutually exclusive branches. +- `taint-assignment-kills-source?` checks whether the assignment kill and sink + are in sibling mutually exclusive branches. This is necessary when the source + is outside the `case` but the clean assignment is inside one branch. +- `propagator-blocked-by-sanitizer?` checks whether the sanitizer and + propagator source site are in sibling mutually exclusive branches. + +The Ruby helper is intentionally scoped and text based. It walks upward from a +finding to the nearest less-indented `when` or `else` clause, then walks further +up to the matching same-indentation `case`. Two findings are mutually exclusive +only when they have the same case start, the same clause indentation, and +different clause starts. This handles the upstream fixture and avoids changing +the broader taint model into a full control-flow graph. + +Smoke coverage added: + +```scheme +(test-case "scan Ruby taint keeps sibling case branches separate" + ...) +``` + +Focused upstream verification: + +```text +upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared +``` + +Full Ruby taint verification: + +```text +upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared +``` + +Shared taint regressions checked: + +```text +tests/tainting_rules/python: 12 passed, 0 mismatched, 0 Jerboa errors +tests/tainting_rules/js: 11 passed, 0 mismatched, 0 Jerboa errors +``` + ### Django Safe URL Taint Source Guard The upstream Python taint case `simpl_django_redirect` has sources shaped like: @@ -810,7 +929,22 @@ make test Result: ```text -311 tests, 311 passed, 0 failed +312 tests, 312 passed, 0 failed +``` + +Focused upstream Ruby taint `switch`: + +```sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \ +UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/ruby \ +CASE_REGEX='^(switch)$' LIST_MISMATCHES=1 MAX_DIFFS=300 \ +tests/oracle/upstream-sweep.sh +``` + +Result: + +```text +upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared ``` Focused upstream Python taint `simpl_django_redirect`: @@ -1036,23 +1170,42 @@ Result: upstream-sweep: 12 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 12 compared ``` +Full Ruby tainting-rule subdirectory: + +```sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \ +UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/ruby \ +LIST_MISMATCHES=1 MAX_DIFFS=300 tests/oracle/upstream-sweep.sh +``` + +Result: + +```text +upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared +``` + ## Cross-Language Tainting-Rule Frontier -The upstream `tests/tainting_rules/js` and `tests/tainting_rules/python` -directories are clean as of this checkpoint. +The upstream `tests/tainting_rules/js`, `tests/tainting_rules/python`, and +`tests/tainting_rules/ruby` directories are clean as of this checkpoint. Additional subdirectory sweeps found: - `java`: 2 passed / 0 mismatched / 0 Jerboa errors. - `scala`: 1 passed / 0 mismatched / 0 Jerboa errors. - `ts`: 1 passed / 0 mismatched / 0 Jerboa errors. -- `dart`: 0 passed / 2 mismatched. -- `go`: 4 passed / 5 mismatched. -- `php`: 2 passed / 4 mismatched. +- `dart`: 0 passed / 2 mismatched: `arrays_if`, `try_return`. +- `go`: 4 passed / 5 mismatched: `command-injection`, `continue`, + `goto_dead_code`, `make`, `zip-traversal`. +- `php`: 2 passed / 4 mismatched: `break`, `echo`, `lval_var_sink`, + `no_duplicate_submatches`. - `python`: 12 passed / 0 mismatched / 0 Jerboa errors. -- `ruby`: 0 passed / 1 mismatched. +- `ruby`: 1 passed / 0 mismatched / 0 Jerboa errors. -The next compact target is probably `ruby/switch`, because it is the only -current Ruby tainting-rule mismatch. +The next compact targets are probably Dart `try_return` or `arrays_if`. Dart +has only two current mismatches, so it is the smallest dirty subdirectory after +Ruby. If those turn out to require broad Dart syntax fallback work, move to the +PHP `break`/`echo` control-flow pair or the Go `continue`/`goto_dead_code` +cluster. ## Completed Target: `sanitized_by_side_effect` @@ -1522,20 +1675,30 @@ Actual improvement: ## Recommended Next Target -The next compact target is outside JavaScript and Python taint. A practical -choice is Ruby `switch`, the only current Ruby tainting-rule mismatch: +The next compact target is outside JavaScript, Python, and Ruby taint. A +practical choice is Dart `try_return`, one of only two current Dart +tainting-rule mismatches: ```sh SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \ -UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/ruby \ -CASE_REGEX='^(switch)$' LIST_MISMATCHES=1 MAX_DIFFS=260 \ +UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/dart \ +CASE_REGEX='^(try_return)$' LIST_MISMATCHES=1 MAX_DIFFS=260 \ +tests/oracle/upstream-sweep.sh +``` + +The paired Dart mismatch is `arrays_if`: + +```sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \ +UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/dart \ +CASE_REGEX='^(arrays_if)$' LIST_MISMATCHES=1 MAX_DIFFS=260 \ tests/oracle/upstream-sweep.sh ``` -Earlier frontier data showed `ruby/switch` missing two of three expected -findings. If that target turns out to be a generic text fallback gap, the next -best alternatives are Dart `arrays_if`/`try_return`, then the Go/PHP -taint-control-flow clusters. +If Dart requires more syntax fallback work than is worth doing immediately, +the next best alternatives are the PHP `break`/`echo` pair or the Go +`continue`/`goto_dead_code` pair, because those are likely control-flow +reachability gaps similar to the Python and Ruby fixes already landed. ## Commit Hygiene --- a/lib/semgrep/scan.sls +++ b/lib/semgrep/scan.sls @@ -30376,6 +30376,109 @@ (alist-ref/default b-branch 'chain-start -2)) (not (= (alist-ref/default a-branch 'start -1) (alist-ref/default b-branch 'start -1)))))) + (def (ruby-significant-trimmed-line source line-start) + (let* ([line-end (line-end-after source line-start)] + [trimmed (string-trim + (substring source line-start line-end))]) + (and (> (string-length trimmed) 0) + (not (sg-string-prefix? "#" trimmed)) + trimmed))) + (def (ruby-keyword-line? trimmed keyword) + (let ([keyword-len (string-length keyword)] + [len (string-length trimmed)]) + (and (>= len keyword-len) + (string=? (substring trimmed 0 keyword-len) keyword) + (or (= len keyword-len) + (char-whitespace? (string-ref trimmed keyword-len)))))) + (def (ruby-case-line? trimmed) + (ruby-keyword-line? trimmed "case")) + (def (ruby-case-clause-kind trimmed) + (cond + [(ruby-keyword-line? trimmed "when") 'when] + [(ruby-keyword-line? trimmed "else") 'else] + [else #f])) + (def (ruby-parent-case-clause source offset) + (let* ([line-start (line-start-before source offset)] + [target-indent (line-indent-at-offset source offset)]) + (let loop ([current-start line-start] + [current-indent target-indent]) + (let ([prev-start (python-previous-line-start + source + current-start)]) + (if (not prev-start) + #f + (let ([trimmed (ruby-significant-trimmed-line + source + prev-start)]) + (if (not trimmed) + (loop prev-start current-indent) + (let ([indent (line-indent-at-offset + source + prev-start)]) + (if (>= indent current-indent) + (loop prev-start current-indent) + (let ([kind (ruby-case-clause-kind trimmed)]) + (cond + [kind + (list + (cons 'kind kind) + (cons 'indent indent) + (cons 'start prev-start))] + [(ruby-case-line? trimmed) #f] + [else (loop prev-start indent)]))))))))))) + (def (ruby-case-chain-start source clause) + (let ([kind (alist-ref/default clause 'kind #f)] + [clause-start (alist-ref/default clause 'start #f)] + [clause-indent (alist-ref/default clause 'indent 0)]) + (cond + [(or (eq? kind 'when) (eq? kind 'else)) + (let loop ([current-start clause-start]) + (let ([prev-start (python-previous-line-start + source + current-start)]) + (if (not prev-start) + #f + (let ([trimmed (ruby-significant-trimmed-line + source + prev-start)]) + (if (not trimmed) + (loop prev-start) + (let ([indent (line-indent-at-offset + source + prev-start)]) + (cond + [(< indent clause-indent) #f] + [(> indent clause-indent) (loop prev-start)] + [(ruby-case-line? trimmed) prev-start] + [(ruby-case-clause-kind trimmed) + (loop prev-start)] + [else (loop prev-start)])))))))] + [else #f]))) + (def (ruby-case-branch-info source finding) + (let ([clause (ruby-parent-case-clause + source + (finding-start-offset finding))]) + (and clause + (let ([chain-start (ruby-case-chain-start source clause)]) + (and chain-start + (list + (cons 'start (alist-ref/default clause 'start #f)) + (cons 'indent (alist-ref/default clause 'indent 0)) + (cons 'chain-start chain-start))))))) + (def (ruby-findings-in-mutually-exclusive-case-branches? + source + a + b) + (let ([a-branch (ruby-case-branch-info source a)] + [b-branch (ruby-case-branch-info source b)]) + (and a-branch + b-branch + (= (alist-ref/default a-branch 'indent -1) + (alist-ref/default b-branch 'indent -2)) + (= (alist-ref/default a-branch 'chain-start -1) + (alist-ref/default b-branch 'chain-start -2)) + (not (= (alist-ref/default a-branch 'start -1) + (alist-ref/default b-branch 'start -1)))))) (def (findings-in-mutually-exclusive-if-branches? source a @@ -30387,6 +30490,10 @@ (javascript-findings-in-mutually-exclusive-if-branches? source a + b) + (ruby-findings-in-mutually-exclusive-case-branches? + source + a b))) (def (python-pass-line? trimmed) (or (string=? trimmed "pass") @@ -31601,6 +31708,10 @@ (and sanitizer (let ([source (taint-state-finding source-state)]) (and source + (not (findings-in-mutually-exclusive-if-branches? + source-text + sanitizer + sink)) (or (finding-between? source sanitizer sink) (and (taint-state-token? source-state) (finding-range-contains? sanitizer source) @@ -31839,6 +31950,10 @@ source-text source assignment-finding)) + (not (findings-in-mutually-exclusive-if-branches? + source-text + assignment-finding + sink)) (> (finding-start-offset assignment-finding) (finding-start-offset source)) (< (finding-start-offset assignment-finding) @@ -32187,6 +32302,10 @@ source sanitizer from-finding) + (not (findings-in-mutually-exclusive-if-branches? + source + sanitizer + from-finding)) (finding-between? source-finding sanitizer --- 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" . "3D90FF42092F8FA1") - ("src/semgrep/rule.ss" . "E12C108153C181FA") + ("src/semgrep/scan.ss" . "523EDB0189EB42F5") ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0") - ("src/semgrep/output/text.ss" . "BE476CB84B807FBA") + ("src/semgrep/rule.ss" . "E12C108153C181FA") ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1") + ("src/semgrep/output/text.ss" . "BE476CB84B807FBA") ("src/semgrep/match/structural.ss" . "6FE77014EE9FDCE4") ("src/semgrep/main.ss" . "A4EC9E7F2A09D25E") ("src/semgrep/cli.ss" . "EBDC4B1DAD3F13CC")) --- a/src/semgrep/scan.ss +++ b/src/semgrep/scan.ss @@ -30381,9 +30381,104 @@ (not (= (alist-ref/default a-branch 'start -1) (alist-ref/default b-branch 'start -1)))))) +(def (ruby-significant-trimmed-line source line-start) + (let* ([line-end (line-end-after source line-start)] + [trimmed (string-trim (substring source line-start line-end))]) + (and (> (string-length trimmed) 0) + (not (sg-string-prefix? "#" trimmed)) + trimmed))) + +(def (ruby-keyword-line? trimmed keyword) + (let ([keyword-len (string-length keyword)] + [len (string-length trimmed)]) + (and (>= len keyword-len) + (string=? (substring trimmed 0 keyword-len) keyword) + (or (= len keyword-len) + (char-whitespace? (string-ref trimmed keyword-len)))))) + +(def (ruby-case-line? trimmed) + (ruby-keyword-line? trimmed "case")) + +(def (ruby-case-clause-kind trimmed) + (cond + [(ruby-keyword-line? trimmed "when") 'when] + [(ruby-keyword-line? trimmed "else") 'else] + [else #f])) + +(def (ruby-parent-case-clause source offset) + (let* ([line-start (line-start-before source offset)] + [target-indent (line-indent-at-offset source offset)]) + (let loop ([current-start line-start] [current-indent target-indent]) + (let ([prev-start (python-previous-line-start source current-start)]) + (if (not prev-start) + #f + (let ([trimmed (ruby-significant-trimmed-line source prev-start)]) + (if (not trimmed) + (loop prev-start current-indent) + (let ([indent (line-indent-at-offset source prev-start)]) + (if (>= indent current-indent) + (loop prev-start current-indent) + (let ([kind (ruby-case-clause-kind trimmed)]) + (cond + [kind + (list (cons 'kind kind) + (cons 'indent indent) + (cons 'start prev-start))] + [(ruby-case-line? trimmed) #f] + [else (loop prev-start indent)]))))))))))) + +(def (ruby-case-chain-start source clause) + (let ([kind (alist-ref/default clause 'kind #f)] + [clause-start (alist-ref/default clause 'start #f)] + [clause-indent (alist-ref/default clause 'indent 0)]) + (cond + [(or (eq? kind 'when) (eq? kind 'else)) + (let loop ([current-start clause-start]) + (let ([prev-start (python-previous-line-start source current-start)]) + (if (not prev-start) + #f + (let ([trimmed + (ruby-significant-trimmed-line source prev-start)]) + (if (not trimmed) + (loop prev-start) + (let ([indent (line-indent-at-offset + source + prev-start)]) + (cond + [(< indent clause-indent) #f] + [(> indent clause-indent) (loop prev-start)] + [(ruby-case-line? trimmed) prev-start] + [(ruby-case-clause-kind trimmed) (loop prev-start)] + [else (loop prev-start)])))))))] + [else #f]))) + +(def (ruby-case-branch-info source finding) + (let ([clause (ruby-parent-case-clause + source + (finding-start-offset finding))]) + (and clause + (let ([chain-start (ruby-case-chain-start source clause)]) + (and chain-start + (list (cons 'start (alist-ref/default clause 'start #f)) + (cons 'indent (alist-ref/default clause 'indent 0)) + (cons 'chain-start chain-start))))))) + +(def (ruby-findings-in-mutually-exclusive-case-branches? source a b) + (let ([a-branch (ruby-case-branch-info source a)] + [b-branch (ruby-case-branch-info source b)]) + (and a-branch + b-branch + (= (alist-ref/default a-branch 'indent -1) + (alist-ref/default b-branch 'indent -2)) + (= (alist-ref/default a-branch 'chain-start -1) + (alist-ref/default b-branch 'chain-start -2)) + (not (= (alist-ref/default a-branch 'start -1) + (alist-ref/default b-branch 'start -1)))))) + (def (findings-in-mutually-exclusive-if-branches? source a b) (or (python-findings-in-mutually-exclusive-if-branches? source a b) - (javascript-findings-in-mutually-exclusive-if-branches? source a b))) + (javascript-findings-in-mutually-exclusive-if-branches? source a b) + (ruby-findings-in-mutually-exclusive-case-branches? source a b))) (def (python-pass-line? trimmed) (or (string=? trimmed "pass") @@ -31551,6 +31646,10 @@ (and sanitizer (let ([source (taint-state-finding source-state)]) (and source + (not (findings-in-mutually-exclusive-if-branches? + source-text + sanitizer + sink)) (or (finding-between? source sanitizer sink) (and (taint-state-token? source-state) (finding-range-contains? sanitizer source) @@ -31771,6 +31870,10 @@ source-text source assignment-finding)) + (not (findings-in-mutually-exclusive-if-branches? + source-text + assignment-finding + sink)) (> (finding-start-offset assignment-finding) (finding-start-offset source)) (< (finding-start-offset assignment-finding) @@ -32141,6 +32244,10 @@ source sanitizer from-finding) + (not (findings-in-mutually-exclusive-if-branches? + source + sanitizer + from-finding)) (finding-between? source-finding sanitizer --- a/tests/smoke.ss +++ b/tests/smoke.ss @@ -2650,6 +2650,17 @@ (scan-config-string ruby-config "ruby" "demo.rb" source)]) (check (length findings) => 0))) +(test-case "scan Ruby taint keeps sibling case branches separate" + (let* ([ruby-config + "rules:\n- id: test-switch\n languages: [ruby]\n message: Match Found!\n mode: taint\n pattern-sinks:\n - pattern: sink(...)\n pattern-sources:\n - pattern: source(...)\n pattern-sanitizers:\n - pattern: sanitize(...)\n severity: WARNING\n"] + [findings + (scan-config-string + ruby-config + "ruby" + "demo.rb" + "def f()\n i = source()\n #ruleid: test-switch\n sink(i)\n case x\n when 0\n i = sanitize(i)\n #OK:\n sink(i)\n when 1\n unrelated_call()\n #ruleid: test-switch\n sink(i)\n else\n unrelated_call()\n #ruleid: test-switch\n sink(i)\n end\nend\n")]) + (check (map finding-start-line findings) => '(4 13 17)))) + (test-case "scan Rust response body and macro pattern fallbacks" (let* ([response-config "rules:\n- id: no-direct-response-write\n patterns:\n - pattern: $BUILDER.body(...)\n - pattern-not: $BUILDER.body(\"...\".to_string())\n - metavariable-type:\n metavariable: $BUILDER\n type: HttpResponseBuilder\n message: direct response\n severity: WARNING\n languages: [rust]\n"]