Clear JSX href taint frontier

ober

a928d72692d0f68fe54d416d36a39fd6409a5d3d

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index 6a783e9..90ed893 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -1,10 +1,10 @@
 # Opus 4.8 Handoff: jerboa-semgrep Semgrep Parity
 
-Date: 2026-05-29 01:56 MDT
+Date: 2026-05-29 02:12 MDT
 Workspace: `/Users/user/mine/jerboa-semgrep`
 Sibling upstream Semgrep checkout: `/Users/user/mine/semgrep`
 Packaged Semgrep oracle: `/Users/user/.local/bin/semgrep`
-Current pre-handoff HEAD: `efdf406 Clear parameter source taint frontier`
+Current pre-handoff HEAD: `d767d71 Clear method sequence propagator frontier`
 
 The user wants this project carried forward until the pure Jerboa port reaches
 Semgrep parity. Continue from this state. Do not restart broad discovery from
@@ -52,7 +52,7 @@ make test
 Result:
 
 ```text
-204 tests, 204 passed, 0 failed
+205 tests, 205 passed, 0 failed
 ```
 
 Local oracle:
@@ -67,10 +67,10 @@ Result:
 oracle: 42 passed, 0 failed
 ```
 
-Focused `taint_propagator2` case:
+Focused `taint_react` case:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_propagator2$' LIST_MISMATCHES=1 MAX_DIFFS=5 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_react$' LIST_MISMATCHES=1 MAX_DIFFS=80 tests/oracle/upstream-sweep.sh
 ```
 
 Result:
@@ -82,17 +82,17 @@ upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 com
 Nearby taint guardrail:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_propagator2|taint_param_source1|taint_param_source2|taint_param_source3|taint_typestate|taint_spread_record_big)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_react|taint_safe_comparisons|taint_spread_record_big|taint_param_source3|taint_propagator2)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh
 ```
 
 Result:
 
 ```text
-upstream-sweep: 5 passed, 1 mismatched, 0 jerboa errors, 0 current errors, 6 compared
+upstream-sweep: 3 passed, 2 mismatched, 0 jerboa errors, 0 current errors, 5 compared
 ```
 
-The remaining mismatch in that guardrail is the known frontier
-`taint_spread_record_big`.
+The two remaining mismatches in that guardrail are `taint_safe_comparisons`
+and the known wider frontier `taint_spread_record_big`.
 
 Full upstream sweep:
 
@@ -103,7 +103,7 @@ SEMGREP_CURRENT=/Users/user/.local/bin/semgrep LIST_MISMATCHES=1 MAX_DIFFS=0 tes
 Result:
 
 ```text
-upstream-sweep: 227 passed, 12 mismatched, 0 jerboa errors, 2 current errors, 241 compared
+upstream-sweep: 228 passed, 11 mismatched, 0 jerboa errors, 2 current errors, 241 compared
 ```
 
 The two current errors are packaged-Semgrep oracle errors, not Jerboa scanner
@@ -112,53 +112,54 @@ the command above intentionally ran the full sorted 241-case sweep.
 
 ## What Changed In This Checkpoint
 
-This checkpoint clears upstream `taint_propagator2`, reducing the full sweep
+This checkpoint clears upstream `taint_react`, reducing the full sweep
 frontier from:
 
 ```text
-226 passed, 13 mismatched, 0 jerboa errors, 2 current errors
+227 passed, 12 mismatched, 0 jerboa errors, 2 current errors
 ```
 
 to:
 
 ```text
-227 passed, 12 mismatched, 0 jerboa errors, 2 current errors
+228 passed, 11 mismatched, 0 jerboa errors, 2 current errors
 ```
 
 Implementation changes in `src/semgrep/scan.ss`:
 
-- Added a narrow Python method-sequence scanner for patterns shaped like:
+- Added a JavaScript/JSX opening-tag attribute scanner for patterns shaped
+  like:
 
   ```text
-  $Y.f($X)
-  ...
-  $Y.g($Z)
+  <SourceLink ... href=$HREF .../>
   ```
 
-- The scanner emits one finding from the first method call through the second
-  method call and binds `$Y`, `$X`, and `$Z`. This lets the existing taint
-  propagator engine apply `from: $X` / `to: $Z`.
-- The implementation is intentionally scoped to the observed upstream pattern:
-  simple identifier receiver, simple one-argument calls, matching receiver
-  text, and the `.f(...)` / `.g(...)` method names.
-- This avoids changing general structural matching for all multiline Python
-  statement sequences while still moving the upstream parity frontier.
+- The scanner parses the JSX tag name, attribute name, and metavariable from
+  the pattern, scans target opening tags, and binds the expression inside
+  braced JSX attributes such as `href={rule.source_uri}`.
+- It returns a finding covering the opening tag so existing
+  `focus-metavariable` logic can narrow the report to `$HREF`. Existing taint
+  sanitizer logic then blocks safe `href={validateUrl(...)}` cases.
+- The implementation is intentionally scoped to JSX opening tags with braced
+  attribute expressions. It does not attempt to become a general JSX structural
+  matcher.
 
 New smoke coverage in `tests/smoke.ss`:
 
 ```text
-scan Python taint propagator method sequence
+scan JavaScript taint focused JSX href attribute
 ```
 
 ## Recently Cleared Cases
 
-Clean in the two most recent checkpoints:
+Clean in the three most recent checkpoints:
 
 ```text
 taint_param_source1
 taint_param_source2
 taint_param_source3
 taint_propagator2
+taint_react
 ```
 
 Relevant upstream files:
@@ -172,24 +173,34 @@ Relevant upstream files:
 /Users/user/mine/semgrep/tests/rules/taint_param_source3.js
 /Users/user/mine/semgrep/tests/rules/taint_propagator2.yaml
 /Users/user/mine/semgrep/tests/rules/taint_propagator2.py
+/Users/user/mine/semgrep/tests/rules/taint_react.yaml
+/Users/user/mine/semgrep/tests/rules/taint_react.js
 ```
 
-Important isolation finding for `taint_propagator2`:
+Important isolation finding for `taint_react`:
+
+- `pattern: rule` matched the relevant source tokens, including
+  `rule.source_uri`.
+- `pattern: url` matched the callback parameter and uses.
+- `pattern: validateUrl(...)` matched the two safe sanitizer calls.
+- The sink formula returned zero before this checkpoint:
+
+  ```yaml
+  patterns:
+    - pattern-inside: |
+        (<SourceLink ... href=$HREF .../>)
+    - focus-metavariable: $HREF
+  ```
 
-- `pattern: user_input` matched line 2.
-- Implicit assignment `x = user_input` matched and can taint `x`.
-- The sink formula `sink($SINK)` with focus matched `z` at line 7.
-- The custom propagator pattern itself returned zero before this checkpoint.
-  The issue was not assignment kill or sink focus; it was missing structural
-  support for the multiline method-call sequence.
+- The missing piece was JSX attribute binding for `$HREF`; the taint engine and
+  sanitizer blocking were already sufficient once that binding existed.
 
 ## Current Full-Sweep Frontier
 
 The current full sorted upstream sweep compared 241 rule/target pairs and has
-these 12 mismatches:
+these 11 mismatches:
 
 ```text
-taint_react
 taint_safe_comparisons
 taint_spread_record_big
 taint_unify_mvars
@@ -209,23 +220,23 @@ Use this command to refresh the full list:
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep LIST_MISMATCHES=1 MAX_DIFFS=0 tests/oracle/upstream-sweep.sh
 ```
 
-## Recommended Next Target: taint_react
+## Recommended Next Target: taint_safe_comparisons
 
-The next sorted frontier is `taint_react`.
+The next sorted frontier is `taint_safe_comparisons`.
 
 Focused command:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_react$' LIST_MISMATCHES=1 MAX_DIFFS=1 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_safe_comparisons$' LIST_MISMATCHES=1 MAX_DIFFS=80 tests/oracle/upstream-sweep.sh
 ```
 
 Current result:
 
 ```text
-MISMATCH taint_react
-@@ -1,2 +0,0 @@
--(finding "href-semgrep-app" ".../taint_react.js" 50 19 1301 50 34 1316 "WARNING" "Found logging of sensitive data" "")
--(finding "href-semgrep-app" ".../taint_react.js" 61 75 1627 61 78 1630 "WARNING" "Found logging of sensitive data" "")
+MISMATCH taint_safe_comparisons
+@@ -1 +1,2 @@
+ (finding "tainting" ".../taint_safe_comparisons.py" 3 1 32 3 8 39 "ERROR" "Test" "")
++(finding "tainting" ".../taint_safe_comparisons.py" 6 1 75 6 8 82 "ERROR" "Test" "")
 upstream-sweep: 0 passed, 1 mismatched, 0 jerboa errors, 0 current errors, 1 compared
 ```
 
@@ -233,43 +244,39 @@ Rule:
 
 ```yaml
 rules:
-- id: href-semgrep-app
+- id: tainting
   mode: taint
+  options:
+    taint_assume_safe_comparisons: true
   pattern-sources:
-    - pattern: rule
-    - pattern: url
-  pattern-sanitizers:
-    - pattern: validateUrl(...)
+    - pattern: |
+        "tainted"
   pattern-sinks:
-    - patterns:
-        - pattern-inside: |
-            (<SourceLink ... href=$HREF .../>)
-        - focus-metavariable: $HREF
-  languages: [js]
+    - pattern: sink(...)
+  languages: [python]
 ```
 
-Target highlights:
+Target:
 
-```jsx
-href={validateUrl(rule.source_uri)}     // ok
-href={validateUrl(url)}                 // ok
-href={rule.source_uri}                  // expected finding on rule.source_uri
-<SourceLink ... href={url}>             // expected finding on url
+```python
+x = "tainted"
+#ruleid: tainting
+sink(x)
+y = x != "something"
+#ok: tainting
+sink(y)
 ```
 
 Likely fix area:
 
-- JSX `pattern-inside` and focus handling for attributes. The sink formula is
-  only a `pattern-inside` with `href=$HREF` plus `focus-metavariable`, so the
-  scanner needs to produce a focused `$HREF` finding from JSX attributes.
-- Sanitizer `validateUrl(...)` already exists in the rule; verify the source
-  side first, then isolate the sink formula separately:
-
-  ```sh
-  # Source-only probes: pattern rule, url
-  # Sink-only probe: patterns with the SourceLink pattern-inside and focus
-  # Sanitizer-only probe: pattern validateUrl(...)
-  ```
+- The option is named `taint_assume_safe_comparisons`; current code has
+  `taint-assume-safe-booleans?` and blocks comparisons only when
+  `taint_assume_safe_booleans` is enabled.
+- Implement option recognition for `taint_assume_safe_comparisons` and ensure
+  the assignment `y = x != "something"` either does not propagate taint to `y`
+  or is treated as safe when `sink(y)` is evaluated.
+- Add a smoke test that keeps `sink(x)` positive but suppresses `sink(y)` for
+  the comparison-derived boolean.
 
 ## Useful Commands
 
@@ -325,10 +332,10 @@ SCM
   `function ...` lines are recognized as simple scopes. This avoids sibling
   leaks without breaking nested callback taint cases already covered by smoke
   tests.
-- After touching JSX sink logic, run at minimum:
+- After touching comparison-taint logic, run at minimum:
 
   ```sh
   make test
-  SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_react|taint_spread_record_big|taint_param_source3)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh
+  SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_safe_comparisons|taint_react|taint_param_source3|taint_propagator2)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh
   SEMGREP_CURRENT=/Users/user/.local/bin/semgrep make oracle
   ```
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index 5a5f933..b2c40e3 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -2593,6 +2593,261 @@
          pattern)
        (scan-javascript-jsx-style-pattern-with-bindings rule path
          source pattern '()))
+  (def (jsx-name-char? ch)
+       (or (char-alphabetic? ch)
+           (char-numeric? ch)
+           (char=? ch #\_)
+           (char=? ch #\$)
+           (char=? ch #\-)
+           (char=? ch #\:)
+           (char=? ch #\.)))
+  (def (jsx-name-range-at source start)
+       (let ([len (string-length source)])
+         (and (< start len)
+              (jsx-name-char? (string-ref source start))
+              (let loop ([i (+ start 1)])
+                (if (and (< i len) (jsx-name-char? (string-ref source i)))
+                    (loop (+ i 1))
+                    (cons start i))))))
+  (def (jsx-name-boundary? source index)
+       (or (<= index 0)
+           (>= index (string-length source))
+           (not (jsx-name-char? (string-ref source index)))))
+  (def (skip-whitespace-backward-exclusive source start end)
+       (let loop ([i (- end 1)])
+         (cond
+           [(< i start) start]
+           [(char-whitespace? (string-ref source i)) (loop (- i 1))]
+           [else (+ i 1)])))
+  (def (trim-source-range source start end)
+       (let ([trimmed-start (skip-whitespace source start)])
+         (cons
+           trimmed-start
+           (skip-whitespace-backward-exclusive
+             source
+             trimmed-start
+             end))))
+  (def (jsx-metavariable-name-end pattern name-start)
+       (let ([len (string-length pattern)])
+         (let loop ([i name-start])
+           (if (and (< i len)
+                    (let ([ch (string-ref pattern i)])
+                      (or (char-alphabetic? ch)
+                          (char-numeric? ch)
+                          (char=? ch #\_))))
+               (loop (+ i 1))
+               i))))
+  (def (jsx-attribute-name-before-equals pattern eq-index)
+       (let ([end (skip-whitespace-backward-exclusive
+                    pattern
+                    0
+                    eq-index)])
+         (let loop ([i (- end 1)])
+           (cond
+             [(< i 0) #f]
+             [(jsx-name-char? (string-ref pattern i)) (loop (- i 1))]
+             [else
+              (let ([start (+ i 1)])
+                (and (< start end) (substring pattern start end)))]))))
+  (def (jsx-attribute-metavariable-spec-after
+         pattern
+         dollar-index)
+       (let* ([name-start (+ dollar-index 1)]
+              [name-end (jsx-metavariable-name-end pattern name-start)])
+         (and (> name-end name-start)
+              (let ([eq-index (let loop ([i (- dollar-index 1)])
+                                (cond
+                                  [(< i 0) #f]
+                                  [(char-whitespace?
+                                     (string-ref pattern i))
+                                   (loop (- i 1))]
+                                  [(char=? (string-ref pattern i) #\=) i]
+                                  [else #f]))])
+                (and eq-index
+                     (let ([attr (jsx-attribute-name-before-equals
+                                   pattern
+                                   eq-index)])
+                       (and attr
+                            (list
+                              (cons 'attribute attr)
+                              (cons
+                                'metavariable
+                                (substring
+                                  pattern
+                                  name-start
+                                  name-end))))))))))
+  (def (jsx-pattern-tag-name pattern)
+       (let ([lt (string-find-substring pattern "<")])
+         (and lt
+              (let* ([name-start (skip-whitespace pattern (+ lt 1))]
+                     [range (jsx-name-range-at pattern name-start)])
+                (and range (substring pattern (car range) (cdr range)))))))
+  (def (jsx-attribute-metavariable-pattern-spec pattern)
+       (let ([tag (jsx-pattern-tag-name pattern)])
+         (and tag
+              (let loop ([start 0])
+                (let ([dollar (string-find-substring-from
+                                pattern
+                                "$"
+                                start)])
+                  (and dollar
+                       (or (let ([spec (jsx-attribute-metavariable-spec-after
+                                         pattern
+                                         dollar)])
+                             (and spec (cons (cons 'tag tag) spec)))
+                           (loop (+ dollar 1)))))))))
+  (def (jsx-opening-tag-end source start)
+       (let ([len (string-length source)])
+         (let loop ([i start]
+                    [brace-depth 0]
+                    [state 'normal]
+                    [escaped? #f])
+           (cond
+             [(>= i len) #f]
+             [(eq? state 'string)
+              (let ([ch (string-ref source i)])
+                (cond
+                  [escaped? (loop (+ i 1) brace-depth state #f)]
+                  [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)]
+                  [(char=? ch #\") (loop (+ i 1) brace-depth 'normal #f)]
+                  [else (loop (+ i 1) brace-depth state #f)]))]
+             [(eq? state 'single)
+              (let ([ch (string-ref source i)])
+                (cond
+                  [escaped? (loop (+ i 1) brace-depth state #f)]
+                  [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)]
+                  [(char=? ch #\') (loop (+ i 1) brace-depth 'normal #f)]
+                  [else (loop (+ i 1) brace-depth state #f)]))]
+             [(eq? state 'template)
+              (let ([ch (string-ref source i)])
+                (cond
+                  [escaped? (loop (+ i 1) brace-depth state #f)]
+                  [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)]
+                  [(char=? ch #\`) (loop (+ i 1) brace-depth 'normal #f)]
+                  [else (loop (+ i 1) brace-depth state #f)]))]
+             [else
+              (let ([ch (string-ref source i)])
+                (cond
+                  [(char=? ch #\") (loop (+ i 1) brace-depth 'string #f)]
+                  [(char=? ch #\') (loop (+ i 1) brace-depth 'single #f)]
+                  [(char=? ch #\`) (loop (+ i 1) brace-depth 'template #f)]
+                  [(char=? ch #\{)
+                   (loop (+ i 1) (+ brace-depth 1) state #f)]
+                  [(char=? ch #\})
+                   (loop (+ i 1) (max 0 (- brace-depth 1)) state #f)]
+                  [(and (= brace-depth 0) (char=? ch #\>)) (+ i 1)]
+                  [else (loop (+ i 1) brace-depth state #f)]))]))))
+  (def (jsx-braced-attribute-value-range
+         source
+         value-start
+         tag-end)
+       (and (< value-start tag-end)
+            (char=? (string-ref source value-start) #\{)
+            (let ([close (find-matching-close-brace
+                           source
+                           value-start)])
+              (and close
+                   (<= close tag-end)
+                   (trim-source-range
+                     source
+                     (+ value-start 1)
+                     (- close 1))))))
+  (def (jsx-attribute-value-range
+         source
+         tag-start
+         tag-end
+         attribute)
+       (let ([attr-len (string-length attribute)])
+         (let loop ([start tag-start])
+           (let ([index (string-find-substring-from
+                          source
+                          attribute
+                          start)])
+             (and index
+                  (< index tag-end)
+                  (if (and (jsx-name-boundary? source (- index 1))
+                           (jsx-name-boundary? source (+ index attr-len)))
+                      (let* ([after-name (skip-whitespace
+                                           source
+                                           (+ index attr-len))]
+                             [value-start0 (and (< after-name tag-end)
+                                                (char=?
+                                                  (string-ref
+                                                    source
+                                                    after-name)
+                                                  #\=)
+                                                (skip-whitespace
+                                                  source
+                                                  (+ after-name 1)))]
+                             [value-range (and value-start0
+                                               (jsx-braced-attribute-value-range
+                                                 source
+                                                 value-start0
+                                                 tag-end))])
+                        (or value-range (loop (+ index attr-len))))
+                      (loop (+ index 1))))))))
+  (def (scan-javascript-jsx-attribute-pattern-with-bindings rule path source pattern initial-bindings)
+       (let ([spec (jsx-attribute-metavariable-pattern-spec
+                     pattern)])
+         (and spec
+              (let* ([tag (alist-ref/default spec 'tag #f)]
+                     [attribute (alist-ref/default spec 'attribute #f)]
+                     [name (alist-ref/default spec 'metavariable #f)]
+                     [needle (string-append "<" tag)]
+                     [needle-len (string-length needle)]
+                     [len (string-length source)])
+                (let loop ([start 0] [acc '()])
+                  (let ([tag-start (string-find-substring-from
+                                     source
+                                     needle
+                                     start)])
+                    (if (not tag-start)
+                        (reverse acc)
+                        (let* ([tag-name-end (+ tag-start needle-len)]
+                               [tag-end (and (jsx-name-boundary?
+                                               source
+                                               tag-name-end)
+                                             (jsx-opening-tag-end
+                                               source
+                                               tag-name-end))]
+                               [value-range (and tag-end
+                                                 (jsx-attribute-value-range
+                                                   source
+                                                   tag-name-end
+                                                   tag-end
+                                                   attribute))]
+                               [binding-start (and value-range
+                                                   (car value-range))]
+                               [binding-end (and value-range
+                                                 (cdr value-range))]
+                               [bindings (and binding-start
+                                              binding-end
+                                              (< binding-start binding-end)
+                                              (merge-binding-list
+                                                initial-bindings
+                                                (list
+                                                  (cons
+                                                    name
+                                                    (metavariable-binding-for-range
+                                                      name
+                                                      source
+                                                      binding-start
+                                                      binding-end)))))]
+                               [finding (and bindings
+                                             (finding-for-range-with-bindings rule path source tag-start
+                                               tag-end bindings))]
+                               [next (if tag-end
+                                         (max (+ tag-start 1) tag-end)
+                                         (+ tag-start needle-len))])
+                          (loop
+                            next
+                            (if finding (cons finding acc) acc))))))))))
+  (def (scan-javascript-jsx-attribute-pattern
+         rule
+         path
+         source
+         pattern)
+       (scan-javascript-jsx-attribute-pattern-with-bindings rule path source pattern '()))
   (def (javascript-record-pattern-kind pattern)
        (let ([trimmed (string-trim pattern)])
          (cond
@@ -4294,6 +4549,12 @@
                      source
                      (cdr entry)))
               (and (javascript-like-language? language)
+                   (scan-javascript-jsx-attribute-pattern
+                     rule
+                     path
+                     source
+                     (cdr entry)))
+              (and (javascript-like-language? language)
                    (scan-javascript-record-pattern
                      rule
                      path
@@ -4485,6 +4746,8 @@
               (and (javascript-like-language? language)
                    (scan-javascript-jsx-style-pattern-with-bindings rule path source (cdr entry) initial-bindings))
               (and (javascript-like-language? language)
+                   (scan-javascript-jsx-attribute-pattern-with-bindings rule path source (cdr entry) initial-bindings))
+              (and (javascript-like-language? language)
                    (scan-javascript-record-pattern
                      rule
                      path
@@ -11006,6 +11269,12 @@
                          source
                          (rule-pattern rule)))
                   (and (javascript-like-language? language)
+                       (scan-javascript-jsx-attribute-pattern
+                         rule
+                         path
+                         source
+                         (rule-pattern rule)))
+                  (and (javascript-like-language? language)
                        (scan-javascript-record-pattern
                          rule
                          path
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index cfb67f1..f0d6766 100644
--- a/src/.jerbuild-hashes
+++ b/src/.jerbuild-hashes
@@ -3,11 +3,11 @@
   ("src/semgrep/output/json.ss" . "293881CFA2ADB7BC")
   ("src/semgrep/lang.ss" . "7E5441BD00A7F1D4")
   ("src/semgrep/parse/parse-target.ss" . "E74854DDDACF6BA")
-  ("src/semgrep/scan.ss" . "2D06D9BC49A20C00")
-  ("src/semgrep/rule.ss" . "E12C108153C181FA")
+  ("src/semgrep/scan.ss" . "886D36FBE7824066")
   ("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" . "F7B63A9A6FA028B")
   ("src/semgrep/main.ss" . "A4EC9E7F2A09D25E")
   ("src/semgrep/cli.ss" . "D56FC2D2EB449BA6"))
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index 0805ab2..54aa61f 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -2738,6 +2738,250 @@
     pattern
     '()))
 
+(def (jsx-name-char? ch)
+  (or (char-alphabetic? ch)
+      (char-numeric? ch)
+      (char=? ch #\_)
+      (char=? ch #\$)
+      (char=? ch #\-)
+      (char=? ch #\:)
+      (char=? ch #\.)))
+
+(def (jsx-name-range-at source start)
+  (let ([len (string-length source)])
+    (and (< start len)
+         (jsx-name-char? (string-ref source start))
+         (let loop ([i (+ start 1)])
+           (if (and (< i len)
+                    (jsx-name-char? (string-ref source i)))
+               (loop (+ i 1))
+               (cons start i))))))
+
+(def (jsx-name-boundary? source index)
+  (or (<= index 0)
+      (>= index (string-length source))
+      (not (jsx-name-char? (string-ref source index)))))
+
+(def (skip-whitespace-backward-exclusive source start end)
+  (let loop ([i (- end 1)])
+    (cond
+      [(< i start) start]
+      [(char-whitespace? (string-ref source i)) (loop (- i 1))]
+      [else (+ i 1)])))
+
+(def (trim-source-range source start end)
+  (let ([trimmed-start (skip-whitespace source start)])
+    (cons trimmed-start
+          (skip-whitespace-backward-exclusive
+            source
+            trimmed-start
+            end))))
+
+(def (jsx-metavariable-name-end pattern name-start)
+  (let ([len (string-length pattern)])
+    (let loop ([i name-start])
+      (if (and (< i len)
+               (let ([ch (string-ref pattern i)])
+                 (or (char-alphabetic? ch)
+                     (char-numeric? ch)
+                     (char=? ch #\_))))
+          (loop (+ i 1))
+          i))))
+
+(def (jsx-attribute-name-before-equals pattern eq-index)
+  (let ([end (skip-whitespace-backward-exclusive pattern 0 eq-index)])
+    (let loop ([i (- end 1)])
+      (cond
+        [(< i 0) #f]
+        [(jsx-name-char? (string-ref pattern i)) (loop (- i 1))]
+        [else
+         (let ([start (+ i 1)])
+           (and (< start end)
+                (substring pattern start end)))]))))
+
+(def (jsx-attribute-metavariable-spec-after pattern dollar-index)
+  (let* ([name-start (+ dollar-index 1)]
+         [name-end (jsx-metavariable-name-end pattern name-start)])
+    (and (> name-end name-start)
+         (let ([eq-index
+                (let loop ([i (- dollar-index 1)])
+                  (cond
+                    [(< i 0) #f]
+                    [(char-whitespace? (string-ref pattern i))
+                     (loop (- i 1))]
+                    [(char=? (string-ref pattern i) #\=) i]
+                    [else #f]))])
+           (and eq-index
+                (let ([attr (jsx-attribute-name-before-equals
+                              pattern
+                              eq-index)])
+                  (and attr
+                       (list
+                         (cons 'attribute attr)
+                         (cons 'metavariable
+                               (substring pattern name-start name-end))))))))))
+
+(def (jsx-pattern-tag-name pattern)
+  (let ([lt (string-find-substring pattern "<")])
+    (and lt
+         (let* ([name-start (skip-whitespace pattern (+ lt 1))]
+                [range (jsx-name-range-at pattern name-start)])
+           (and range
+                (substring pattern (car range) (cdr range)))))))
+
+(def (jsx-attribute-metavariable-pattern-spec pattern)
+  (let ([tag (jsx-pattern-tag-name pattern)])
+    (and tag
+         (let loop ([start 0])
+           (let ([dollar (string-find-substring-from pattern "$" start)])
+             (and dollar
+                  (or (let ([spec
+                             (jsx-attribute-metavariable-spec-after
+                               pattern
+                               dollar)])
+                        (and spec (cons (cons 'tag tag) spec)))
+                      (loop (+ dollar 1)))))))))
+
+(def (jsx-opening-tag-end source start)
+  (let ([len (string-length source)])
+    (let loop ([i start] [brace-depth 0] [state 'normal] [escaped? #f])
+      (cond
+        [(>= i len) #f]
+        [(eq? state 'string)
+         (let ([ch (string-ref source i)])
+           (cond
+             [escaped? (loop (+ i 1) brace-depth state #f)]
+             [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)]
+             [(char=? ch #\") (loop (+ i 1) brace-depth 'normal #f)]
+             [else (loop (+ i 1) brace-depth state #f)]))]
+        [(eq? state 'single)
+         (let ([ch (string-ref source i)])
+           (cond
+             [escaped? (loop (+ i 1) brace-depth state #f)]
+             [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)]
+             [(char=? ch #\') (loop (+ i 1) brace-depth 'normal #f)]
+             [else (loop (+ i 1) brace-depth state #f)]))]
+        [(eq? state 'template)
+         (let ([ch (string-ref source i)])
+           (cond
+             [escaped? (loop (+ i 1) brace-depth state #f)]
+             [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)]
+             [(char=? ch #\`) (loop (+ i 1) brace-depth 'normal #f)]
+             [else (loop (+ i 1) brace-depth state #f)]))]
+        [else
+         (let ([ch (string-ref source i)])
+           (cond
+             [(char=? ch #\") (loop (+ i 1) brace-depth 'string #f)]
+             [(char=? ch #\') (loop (+ i 1) brace-depth 'single #f)]
+             [(char=? ch #\`) (loop (+ i 1) brace-depth 'template #f)]
+             [(char=? ch #\{) (loop (+ i 1) (+ brace-depth 1) state #f)]
+             [(char=? ch #\})
+              (loop (+ i 1) (max 0 (- brace-depth 1)) state #f)]
+             [(and (= brace-depth 0) (char=? ch #\>)) (+ i 1)]
+             [else (loop (+ i 1) brace-depth state #f)]))]))))
+
+(def (jsx-braced-attribute-value-range source value-start tag-end)
+  (and (< value-start tag-end)
+       (char=? (string-ref source value-start) #\{)
+       (let ([close (find-matching-close-brace source value-start)])
+         (and close
+              (<= close tag-end)
+              (trim-source-range source (+ value-start 1) (- close 1))))))
+
+(def (jsx-attribute-value-range source tag-start tag-end attribute)
+  (let ([attr-len (string-length attribute)])
+    (let loop ([start tag-start])
+      (let ([index (string-find-substring-from source attribute start)])
+        (and index
+             (< index tag-end)
+             (if (and (jsx-name-boundary? source (- index 1))
+                      (jsx-name-boundary? source (+ index attr-len)))
+                 (let* ([after-name (skip-whitespace source
+                                                     (+ index attr-len))]
+                        [value-start0
+                         (and (< after-name tag-end)
+                              (char=? (string-ref source after-name) #\=)
+                              (skip-whitespace source (+ after-name 1)))]
+                        [value-range
+                         (and value-start0
+                              (jsx-braced-attribute-value-range
+                                source
+                                value-start0
+                                tag-end))])
+                   (or value-range
+                       (loop (+ index attr-len))))
+                 (loop (+ index 1))))))))
+
+(def (scan-javascript-jsx-attribute-pattern-with-bindings
+       rule
+       path
+       source
+       pattern
+       initial-bindings)
+  (let ([spec (jsx-attribute-metavariable-pattern-spec pattern)])
+    (and spec
+         (let* ([tag (alist-ref/default spec 'tag #f)]
+                [attribute (alist-ref/default spec 'attribute #f)]
+                [name (alist-ref/default spec 'metavariable #f)]
+                [needle (string-append "<" tag)]
+                [needle-len (string-length needle)]
+                [len (string-length source)])
+           (let loop ([start 0] [acc '()])
+             (let ([tag-start (string-find-substring-from
+                                source
+                                needle
+                                start)])
+               (if (not tag-start)
+                   (reverse acc)
+                   (let* ([tag-name-end (+ tag-start needle-len)]
+                          [tag-end
+                           (and (jsx-name-boundary? source tag-name-end)
+                                (jsx-opening-tag-end source tag-name-end))]
+                          [value-range
+                           (and tag-end
+                                (jsx-attribute-value-range
+                                  source
+                                  tag-name-end
+                                  tag-end
+                                  attribute))]
+                          [binding-start (and value-range (car value-range))]
+                          [binding-end (and value-range (cdr value-range))]
+                          [bindings
+                           (and binding-start
+                                binding-end
+                                (< binding-start binding-end)
+                                (merge-binding-list
+                                  initial-bindings
+                                  (list
+                                    (cons name
+                                          (metavariable-binding-for-range
+                                            name
+                                            source
+                                            binding-start
+                                            binding-end)))))]
+                          [finding
+                           (and bindings
+                                (finding-for-range-with-bindings
+                                  rule
+                                  path
+                                  source
+                                  tag-start
+                                  tag-end
+                                  bindings))]
+                          [next (if tag-end
+                                    (max (+ tag-start 1) tag-end)
+                                    (+ tag-start needle-len))])
+                     (loop next
+                           (if finding (cons finding acc) acc))))))))))
+
+(def (scan-javascript-jsx-attribute-pattern rule path source pattern)
+  (scan-javascript-jsx-attribute-pattern-with-bindings
+    rule
+    path
+    source
+    pattern
+    '()))
+
 (def (javascript-record-pattern-kind pattern)
   (let ([trimmed (string-trim pattern)])
     (cond
@@ -4467,6 +4711,12 @@
                 source
                 (cdr entry)))
          (and (javascript-like-language? language)
+              (scan-javascript-jsx-attribute-pattern
+                rule
+                path
+                source
+                (cdr entry)))
+         (and (javascript-like-language? language)
               (scan-javascript-record-pattern
                 rule
                 path
@@ -4734,6 +4984,13 @@
                 (cdr entry)
                 initial-bindings))
          (and (javascript-like-language? language)
+              (scan-javascript-jsx-attribute-pattern-with-bindings
+                rule
+                path
+                source
+                (cdr entry)
+                initial-bindings))
+         (and (javascript-like-language? language)
               (scan-javascript-record-pattern
                 rule
                 path
@@ -11629,6 +11886,12 @@
                     source
                     (rule-pattern rule)))
              (and (javascript-like-language? language)
+                  (scan-javascript-jsx-attribute-pattern
+                    rule
+                    path
+                    source
+                    (rule-pattern rule)))
+             (and (javascript-like-language? language)
                   (scan-javascript-record-pattern
                     rule
                     path
diff --git a/tests/smoke.ss b/tests/smoke.ss
index 6fba6f6..23de931 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -2030,6 +2030,23 @@
     (check (finding-start-line (car findings)) => 6)
     (check (finding-start-col (car findings)) => 10)))
 
+(test-case "scan JavaScript taint focused JSX href attribute"
+  (let* ([taint-config
+          "rules:\n  - id: demo.taint.jsx.href\n    mode: taint\n    languages: [javascript]\n    message: jsx href\n    severity: WARNING\n    pattern-sources:\n      - pattern: rule\n      - pattern: url\n    pattern-sanitizers:\n      - pattern: validateUrl(...)\n    pattern-sinks:\n      - patterns:\n          - pattern-inside: |\n              (<SourceLink ... href=$HREF .../>)\n          - focus-metavariable: $HREF\n"]
+         [findings
+          (scan-config-string
+            taint-config
+            "javascript"
+            "demo.js"
+            "const Sources = ({ rule, url }) => (\n  <div>\n    <SourceLink href={validateUrl(rule.source_uri)} />\n    <SourceLink href={rule.source_uri} />\n    <SourceLink href={validateUrl(url)} />\n    <SourceLink href={url} />\n  </div>\n);\n")])
+    (check (length findings) => 2)
+    (check (finding-start-line (car findings)) => 4)
+    (check (finding-start-col (car findings)) => 23)
+    (check (finding-end-col (car findings)) => 38)
+    (check (finding-start-line (cadr findings)) => 6)
+    (check (finding-start-col (cadr findings)) => 23)
+    (check (finding-end-col (cadr findings)) => 26)))
+
 (test-case "scan taint propagation stays within Python function scope"
   (let* ([taint-config
           "rules:\n  - id: demo.taint.propagator.scope\n    mode: taint\n    languages: [python]\n    message: scoped propagated sink\n    severity: WARNING\n    pattern-sources:\n      - label: tainted\n        pattern: user_input\n      - label: safe\n        pattern: safe_data\n    pattern-propagators:\n      - pattern: $S.add($A)\n        from: $A\n        to: $S\n    pattern-sinks:\n      - requires: tainted\n        patterns:\n          - pattern: sink($SINK)\n          - focus-metavariable: $SINK\n"]