Clear typed request taint frontier

ober

a1201fb66de7443e29891bc8db6a1440d1a12ade

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index 95fcde1..3b79faa 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -1,6 +1,6 @@
 # Opus 4.8 Handoff: jerboa-semgrep Semgrep Parity
 
-Date: 2026-05-29 00:40 MDT local workspace time
+Date: 2026-05-29 00:51 MDT local workspace time
 Workspace: `/Users/user/mine/jerboa-semgrep`
 Sibling upstream Semgrep checkout: `/Users/user/mine/semgrep`
 Packaged Semgrep oracle: `/Users/user/.local/bin/semgrep`
@@ -31,6 +31,7 @@ taint/dataflow, path and target semantics, autofix, and output schemas.
 Recent checkpoints before this handoff commit:
 
 ```text
+8b8005b Clear Express SQL taint frontier
 38bfcf1 Clear destructuring taint frontier
 19f45f7 Advance lambda and source taint parity
 7cfb751 Clear lambda taint label reach
@@ -75,7 +76,7 @@ make test
 Result:
 
 ```text
-195 tests, 195 passed, 0 failed
+196 tests, 196 passed, 0 failed
 ```
 
 Local oracle:
@@ -90,33 +91,34 @@ Result:
 oracle: 42 passed, 0 failed
 ```
 
-Focused route-template, destructuring, and assignment-only taint guardrail for
-this checkpoint:
+Focused request-host, route-template, destructuring, and assignment-only taint
+guardrail for this checkpoint:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_object_funcdef|taint_nested_record_pattern|taint_no_builtin_props|taint_no_builtin_props1|taint_object_destructure)$' LIST_MISMATCHES=1 MAX_DIFFS=320 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_object_funcdef|taint_object_funcdef_1|taint_nested_record_pattern|taint_no_builtin_props|taint_no_builtin_props1|taint_object_destructure)$' LIST_MISMATCHES=1 MAX_DIFFS=320 tests/oracle/upstream-sweep.sh
 ```
 
 Result:
 
 ```text
-upstream-sweep: 5 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 5 compared
+upstream-sweep: 6 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 6 compared
 ```
 
-This confirms `taint_object_funcdef`, `taint_nested_record_pattern`,
-`taint_no_builtin_props`, `taint_no_builtin_props1`, and
+This confirms `taint_object_funcdef`, `taint_object_funcdef_1`,
+`taint_nested_record_pattern`, `taint_no_builtin_props`,
+`taint_no_builtin_props1`, and
 `taint_object_destructure` are clean in the current tree.
 
-Focused route-template case cleared in this checkpoint:
+Focused request-host and callback-token cases cleared in this checkpoint:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_object_funcdef$' LIST_MISMATCHES=1 MAX_DIFFS=320 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_object_funcdef_1|taint_propagator1)$' LIST_MISMATCHES=1 MAX_DIFFS=320 tests/oracle/upstream-sweep.sh
 ```
 
 Result:
 
 ```text
-upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
+upstream-sweep: 2 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 2 compared
 ```
 
 Focused upstream guardrail containing the field-sensitive cases fixed so far:
@@ -158,54 +160,44 @@ upstream-sweep: 3 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 3 com
 Current full upstream sweep:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_LIMIT=220 LIST_MISMATCHES=1 MAX_DIFFS=320 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep LIST_MISMATCHES=1 MAX_DIFFS=0 tests/oracle/upstream-sweep.sh
 ```
 
 Result:
 
 ```text
-upstream-sweep: 216 passed, 23 mismatched, 0 jerboa errors, 2 current errors, 241 compared
+upstream-sweep: 218 passed, 21 mismatched, 0 jerboa errors, 2 current errors, 241 compared
 ```
 
-Important: `CASE_LIMIT` is not used by `tests/oracle/upstream-sweep.sh`; this
-command effectively ran the full sorted 241-case upstream rule sweep. Use
-`MAX_CASES=220` when a first-220-only guardrail is needed. The two current
-errors are packaged-Semgrep oracle errors, not Jerboa scanner errors.
+The two current errors are packaged-Semgrep oracle errors, not Jerboa scanner
+errors. Use `MAX_CASES=220` when a first-window guardrail is needed; this
+command intentionally ran the full sorted 241-case upstream rule sweep.
 
 ## What Changed In This Checkpoint
 
-This checkpoint clears `taint_object_funcdef`, building on `38bfcf1` which
-cleared `taint_nested_record_pattern`, `taint_no_builtin_props`,
-`taint_no_builtin_props1`, and `taint_object_destructure`.
+This checkpoint clears `taint_object_funcdef_1` and `taint_propagator1`,
+building on `8b8005b` which cleared `taint_object_funcdef`.
 
 Implementation changes in `src/semgrep/scan.ss`:
 
-- `regex-pattern-for-engine` now accepts embedded `(?i)` in addition to a
-  leading `(?i)`. The Semgrep SQL keyword regex in `taint_object_funcdef` is
-  shaped as `.*\b(?i)(select|delete|insert|create|update|alter|drop)\b.*`;
-  Jerboa's regex engine previously raised an internal pregexp error for that
-  form. The implemented behavior treats embedded `(?i)` as "case-insensitive
-  from here onward", which is enough for this fixture and compatible with the
-  common Semgrep rule idiom.
-- JavaScript/TypeScript positive pattern scanning now has a targeted template
-  literal fallback for the sink shape `` `$SQLSTR${$EXPR}...` ``. It finds
-  backtick template literals, binds `$SQLSTR` to the static prefix before the
-  interpolation, binds `$EXPR` to the trimmed expression inside `${...}`, and
-  returns a whole-template finding so the later `focus-metavariable: $EXPR`
-  clause narrows the reported range to the expression.
-- `pattern-inside` now has a route-callback fallback for the exact
-  `$APP.$METHOD(..., function $FUNC($REQ, $RES) {...})` pattern family. It also
-  recognizes Express-style arrow callbacks inside the call, including
-  `app.get('/ok', async (req, res) => { ... })`, and merges `$APP`, `$METHOD`,
-  `$REQ`, and `$RES` bindings onto the source candidate.
-- The route-callback fallback intentionally runs before the older generic
-  function-parameter fallback. The generic fallback could prove containment
-  for this rule but only preserved `$REQ`; the later `$METHOD` regex filter
-  then dropped the source candidate.
+- Built-in JavaScript/TypeScript implicit assignment propagation now includes
+  typed declaration patterns: `const $L: $T = $R`, `let $L: $T = $R`, and
+  `var $L: $T = $R`. Without these, the generic `$L = $R` fallback matched
+  `const host: string = httpOptions.hostname` with `$L=string`, so taint never
+  reached the real alias `host`.
+- Token-taint reach into sinks containing function values is now more precise.
+  The old guard rejected any token source when the sink text contained `=>`,
+  which blocked request-option objects that had an unrelated nested callback.
+  The new guard computes arrow/function body ranges and blocks only when the
+  source token occurrences are solely inside those function bodies.
+- This clears the request-host shape in `taint_object_funcdef_1` and also
+  clears `taint_propagator1`, where tainted tokens outside nested callbacks
+  should still reach the enclosing sink.
 
 New smoke coverage in `tests/smoke.ss`:
 
 ```text
+scan TypeScript taint typed hostname into request options
 scan JavaScript taint Express params into SQL templates
 scan JavaScript taint nested object destructuring assignment
 scan taint only propagates through plain assignments
@@ -231,6 +223,98 @@ subtrees. The ancestor-clean test mirrors `taint_field_sensitive4`:
 
 ## Resolved Recent Cases
 
+`taint_object_funcdef_1` is now cleared:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_object_funcdef_1$' LIST_MISMATCHES=1 MAX_DIFFS=320 tests/oracle/upstream-sweep.sh
+```
+
+Current result:
+
+```text
+upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
+```
+
+Target files:
+
+```text
+/Users/user/mine/semgrep/tests/rules/taint_object_funcdef_1.yaml
+/Users/user/mine/semgrep/tests/rules/taint_object_funcdef_1.ts
+```
+
+Rule shape:
+
+```yaml
+pattern-sources:
+  - pattern: httpOptions.hostname
+  - pattern: opts.hostname
+pattern-sinks:
+  - patterns:
+      - pattern: |
+          (...).request({...})
+      - pattern-not:
+          pattern: |
+            (...).request({
+              beforeRedirect: ($OPTS) => {
+                ...
+                sanitizeIP($OPTS.hostname)
+                ...
+              },
+              ...,
+              lookup: getLookupFunction(..., sanitize)
+            })
+options:
+  symbolic_propagation: true
+```
+
+Relevant target shape:
+
+```typescript
+const httpOptions = urlToHttpOptions(reqUrl);
+const host: string = httpOptions.hostname;
+
+const req: http.ClientRequest =
+  (reqUrl.protocol.startsWith('https') ? https: http).request({
+    host,
+    lookup: getLookupFunction(logger, Boolean(opts.useRoundRobinDns), sanitize),
+    beforeRedirect: (opts) => {
+      // no sanitizeIP call
+    },
+  })
+```
+
+Expected finding range is the whole request call expression from line 8,
+column 33 to line 16, column 3.
+
+What changed:
+
+- `pattern: httpOptions.hostname` and `pattern: (...).request({...})` already
+  matched independently.
+- The missing dataflow was the typed alias
+  `const host: string = httpOptions.hostname`; implicit assignment now binds
+  `$L=host` and `$R=httpOptions.hostname`.
+- The broad request-call sink contains an unrelated nested arrow callback.
+  Token reach no longer rejects the whole sink just because `=>` appears in
+  the sink text; it only rejects tokens that occur solely inside function body
+  ranges.
+
+`taint_propagator1` is now cleared:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_propagator1$' LIST_MISMATCHES=1 MAX_DIFFS=320 tests/oracle/upstream-sweep.sh
+```
+
+Current result:
+
+```text
+upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
+```
+
+This was cleared by the same token-reach refinement. In the previous full
+sweep it missed three expected JavaScript findings in
+`/Users/user/mine/semgrep/tests/rules/taint_propagator1.js`; the current full
+sweep no longer lists it as a mismatch.
+
 `taint_object_funcdef` is now cleared:
 
 ```sh
@@ -793,10 +877,9 @@ What changed:
 ## Current Full-Sweep Frontier
 
 The current full sorted upstream sweep compared 241 rule/target pairs and has
-these 23 mismatches:
+these 21 mismatches:
 
 ```text
-taint_object_funcdef_1
 taint_param_default
 taint_param_default1
 taint_param_default2
@@ -804,7 +887,6 @@ taint_param_default3
 taint_param_source1
 taint_param_source2
 taint_param_source3
-taint_propagator1
 taint_propagator2
 taint_react
 taint_safe_comparisons
@@ -824,57 +906,66 @@ vardef_assign_true2
 Current full-sweep result:
 
 ```text
-upstream-sweep: 216 passed, 23 mismatched, 0 jerboa errors, 2 current errors, 241 compared
+upstream-sweep: 218 passed, 21 mismatched, 0 jerboa errors, 2 current errors, 241 compared
 ```
 
 The two current errors are packaged-Semgrep oracle errors, not Jerboa scanner
-errors. The command accidentally used `CASE_LIMIT=220`, which this script
-ignores, so the result above is a full sweep. For a first-window guardrail,
-use `MAX_CASES=220`.
+errors. This result is from the full sweep command with no `MAX_CASES` limit.
+For a first-window guardrail, use `MAX_CASES=220`.
 
-## Immediate Next Case: taint_object_funcdef_1
+## Immediate Next Case: taint_param_default
 
 Focused command:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_object_funcdef_1$' LIST_MISMATCHES=1 MAX_DIFFS=320 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_param_default$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh
 ```
 
 Current result:
 
 ```text
-MISMATCH taint_object_funcdef_1
-@@ -1 +0,0 @@
--(finding "test" "/Users/user/mine/semgrep/tests/rules/taint_object_funcdef_1.ts" 8 33 209 16 3 556 "WARNING" "Semgrep found a match" "")
+MISMATCH taint_param_default
+@@ -1,4 +0,0 @@
+-(finding "request-session-with-http" "/Users/user/mine/semgrep/tests/rules/taint_param_default.py" 20 18 557 20 21 560 "WARNING" "Detected a request using 'http://'. This request will be unencrypted. Use 'https://' instead." "")
+-(finding "request-session-with-http" "/Users/user/mine/semgrep/tests/rules/taint_param_default.py" 31 20 834 31 23 837 "WARNING" "Detected a request using 'http://'. This request will be unencrypted. Use 'https://' instead." "")
+-(finding "request-session-with-http" "/Users/user/mine/semgrep/tests/rules/taint_param_default.py" 41 29 1117 41 32 1120 "WARNING" "Detected a request using 'http://'. This request will be unencrypted. Use 'https://' instead." "")
+-(finding "request-session-with-http" "/Users/user/mine/semgrep/tests/rules/taint_param_default.py" 9 17 275 9 37 295 "WARNING" "Detected a request using 'http://'. This request will be unencrypted. Use 'https://' instead." "")
 upstream-sweep: 0 passed, 1 mismatched, 0 jerboa errors, 0 current errors, 1 compared
 ```
 
 Rule and target files:
 
 ```text
-/Users/user/mine/semgrep/tests/rules/taint_object_funcdef_1.yaml
-/Users/user/mine/semgrep/tests/rules/taint_object_funcdef_1.ts
+/Users/user/mine/semgrep/tests/rules/taint_param_default.yaml
+/Users/user/mine/semgrep/tests/rules/taint_param_default.py
 ```
 
 Why this should be next:
 
-- It is adjacent to the case just cleared and likely reuses some of the route
-  callback and SQL sink groundwork.
-- It is TypeScript and reports a much wider sink range, so do not assume the
-  `taint_object_funcdef` fix fully generalizes.
-- Start by isolating source formula findings and sink formula findings exactly
-  as was done for `taint_object_funcdef`.
+- It is now the first mismatch in the full sorted upstream sweep.
+- It is the head of the `taint_param_default*` cluster, so a focused fix may
+  clear several adjacent cases.
+- The expected findings cover direct literal calls, assigned URL variables, and
+  Python default parameter values flowing into `requests.Session().get/post`
+  and `.request(...)` sinks.
+- Start by isolating the source formula:
+  `pattern-inside: "..."`, `pattern: $URL`,
+  `metavariable-pattern` with `pattern-regex: http://` and
+  `pattern-not-regex: .*://localhost`.
+- Then isolate sink matching and focus for
+  `requests.Session(...).$W($SINK, ...)` and
+  `requests.Session(...).request($METHOD, $SINK, ...)`.
 
 Useful inspection commands:
 
 ```sh
-sed -n '1,220p' /Users/user/mine/semgrep/tests/rules/taint_object_funcdef_1.yaml
-sed -n '1,220p' /Users/user/mine/semgrep/tests/rules/taint_object_funcdef_1.ts
+sed -n '1,220p' /Users/user/mine/semgrep/tests/rules/taint_param_default.yaml
+sed -n '1,220p' /Users/user/mine/semgrep/tests/rules/taint_param_default.py
 ```
 
-The next likely question is whether Jerboa misses the TypeScript source
-context, the sink range, or both. Avoid broad taint rewrites until that split
-is known.
+The next likely question is whether Jerboa misses string-literal source
+formulas, symbolic/default-parameter propagation, session-method sinks, or a
+combination. Keep the first local smoke narrow enough to distinguish those.
 
 ## Recently Cleared Case Notes: taint_object_funcdef
 
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index 2ff14a6..56a3b86 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -9070,6 +9070,7 @@
               (string=? language "ts")
               (string=? language "tsx"))
           '("$L = $R" "$L[$I] = $R" "const $L = $R" "let $L = $R" "var $L = $R"
+             "const $L: $T = $R" "let $L: $T = $R" "var $L: $T = $R"
              "const { $L } = $R" "let { $L } = $R" "var { $L } = $R"
              "const { $FIELD: $L } = $R" "let { $FIELD: $L } = $R"
              "var { $FIELD: $L } = $R" "const { $FIELD: { $L } } = $R"
@@ -9572,16 +9573,106 @@
                        (- (finding-start-offset source) container-start)
                        (- (finding-end-offset source) container-start)
                        (finding-focused-to-whole-binding? container)))))))
+  (def (token-occurrence-spans text token)
+       (let ([token-len (string-length token)])
+         (if (= token-len 0)
+             '()
+             (let loop ([start 0] [acc '()])
+               (let ([index (string-find-substring-from text token start)])
+                 (if index
+                     (let ([end (+ index token-len)])
+                       (loop
+                         (+ index 1)
+                         (if (and (token-start-boundary? text index)
+                                  (token-end-boundary? text end))
+                             (cons (cons index end) acc)
+                             acc)))
+                     (reverse acc)))))))
+  (def (token-source-texts source-state source-text)
+       (let ([source (taint-state-finding source-state)])
+         (if source
+             (unique-string-list
+               (cons
+                 (finding-text source source-text)
+                 (map (lambda (entry)
+                        (metavariable-binding-text (cdr entry)))
+                      (finding-metavars source))))
+             '())))
+  (def (range-contains-span? range span)
+       (and (<= (car range) (car span))
+            (>= (cdr range) (cdr span))))
+  (def (span-inside-any-range? span ranges)
+       (any?
+         (lambda (range) (range-contains-span? range span))
+         ranges))
+  (def (arrow-function-body-ranges text)
+       (let ([len (string-length text)])
+         (let loop ([offset 0] [acc '()])
+           (let ([arrow (string-find-substring-from text "=>" offset)])
+             (if arrow
+                 (let* ([body-start (skip-whitespace text (+ arrow 2))]
+                        [body-end (and (< body-start len)
+                                       (if (char=?
+                                             (string-ref text body-start)
+                                             #\{)
+                                           (find-matching-close-brace
+                                             text
+                                             body-start)
+                                           len))]
+                        [next (+ arrow 2)])
+                   (loop
+                     next
+                     (if body-end
+                         (cons (cons body-start body-end) acc)
+                         acc)))
+                 (reverse acc))))))
+  (def (function-keyword-body-ranges text)
+       (let loop ([offset 0] [acc '()])
+         (let ([index (string-find-substring-from
+                        text
+                        "function"
+                        offset)])
+           (if index
+               (let* ([body-start (string-find-substring-from
+                                    text
+                                    "{"
+                                    index)]
+                      [body-end (and body-start
+                                     (find-matching-close-brace
+                                       text
+                                       body-start))]
+                      [next (+ index (string-length "function"))])
+                 (loop
+                   next
+                   (if body-end
+                       (cons (cons body-start body-end) acc)
+                       acc)))
+               (reverse acc)))))
+  (def (function-value-body-ranges text)
+       (append
+         (arrow-function-body-ranges text)
+         (function-keyword-body-ranges text)))
   (def (token-source-inside-function-value-sink?
          source-state
          sink
          source-text)
        (and (taint-state-token? source-state)
-            (let ([sink-text (finding-text sink source-text)])
-              (or (string-find-substring sink-text "=>")
-                  (sg-string-prefix?
-                    "function"
-                    (string-trim sink-text))))))
+            (let* ([sink-text (finding-text sink source-text)]
+                   [ranges (function-value-body-ranges sink-text)]
+                   [spans (apply
+                            append
+                            (map (lambda (token)
+                                   (token-occurrence-spans
+                                     sink-text
+                                     token))
+                                 (token-source-texts
+                                   source-state
+                                   source-text)))])
+              (and (not (null? ranges))
+                   (not (null? spans))
+                   (all?
+                     (lambda (span) (span-inside-any-range? span ranges))
+                     spans)))))
   (def (token-text-inside-python-conditional-test? text token)
        (let ([token-len (string-length token)])
          (and (> token-len 0)
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index 1699a08..5abf284 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" . "B22CC16B489E44F2")
-  ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
-  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
+  ("src/semgrep/scan.ss" . "EB53C67D537DE9B3")
   ("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" . "D56FC2D2EB449BA6"))
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index 205687f..435dda6 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -9651,6 +9651,9 @@
        "const $L = $R"
        "let $L = $R"
        "var $L = $R"
+       "const $L: $T = $R"
+       "let $L: $T = $R"
+       "var $L: $T = $R"
        "const { $L } = $R"
        "let { $L } = $R"
        "var { $L } = $R"
@@ -10160,11 +10163,93 @@
                   (- (finding-end-offset source) container-start)
                   (finding-focused-to-whole-binding? container)))))))
 
+(def (token-occurrence-spans text token)
+  (let ([token-len (string-length token)])
+    (if (= token-len 0)
+        '()
+        (let loop ([start 0] [acc '()])
+          (let ([index (string-find-substring-from text token start)])
+            (if index
+                (let ([end (+ index token-len)])
+                  (loop (+ index 1)
+                        (if (and (token-start-boundary? text index)
+                                 (token-end-boundary? text end))
+                            (cons (cons index end) acc)
+                            acc)))
+                (reverse acc)))))))
+
+(def (token-source-texts source-state source-text)
+  (let ([source (taint-state-finding source-state)])
+    (if source
+        (unique-string-list
+          (cons (finding-text source source-text)
+                (map (lambda (entry)
+                       (metavariable-binding-text (cdr entry)))
+                     (finding-metavars source))))
+        '())))
+
+(def (range-contains-span? range span)
+  (and (<= (car range) (car span))
+       (>= (cdr range) (cdr span))))
+
+(def (span-inside-any-range? span ranges)
+  (any? (lambda (range)
+          (range-contains-span? range span))
+        ranges))
+
+(def (arrow-function-body-ranges text)
+  (let ([len (string-length text)])
+    (let loop ([offset 0] [acc '()])
+      (let ([arrow (string-find-substring-from text "=>" offset)])
+        (if arrow
+            (let* ([body-start (skip-whitespace text (+ arrow 2))]
+                   [body-end
+                    (and (< body-start len)
+                         (if (char=? (string-ref text body-start) #\{)
+                             (find-matching-close-brace text body-start)
+                             len))]
+                   [next (+ arrow 2)])
+              (loop next
+                    (if body-end
+                        (cons (cons body-start body-end) acc)
+                        acc)))
+            (reverse acc))))))
+
+(def (function-keyword-body-ranges text)
+  (let loop ([offset 0] [acc '()])
+    (let ([index (string-find-substring-from text "function" offset)])
+      (if index
+          (let* ([body-start (string-find-substring-from text "{" index)]
+                 [body-end
+                  (and body-start
+                       (find-matching-close-brace text body-start))]
+                 [next (+ index (string-length "function"))])
+            (loop next
+                  (if body-end
+                      (cons (cons body-start body-end) acc)
+                      acc)))
+          (reverse acc)))))
+
+(def (function-value-body-ranges text)
+  (append (arrow-function-body-ranges text)
+          (function-keyword-body-ranges text)))
+
 (def (token-source-inside-function-value-sink? source-state sink source-text)
   (and (taint-state-token? source-state)
-       (let ([sink-text (finding-text sink source-text)])
-         (or (string-find-substring sink-text "=>")
-             (sg-string-prefix? "function" (string-trim sink-text))))))
+       (let* ([sink-text (finding-text sink source-text)]
+              [ranges (function-value-body-ranges sink-text)]
+              [spans
+               (apply append
+                      (map (lambda (token)
+                             (token-occurrence-spans sink-text token))
+                           (token-source-texts
+                             source-state
+                             source-text)))])
+         (and (not (null? ranges))
+              (not (null? spans))
+              (all? (lambda (span)
+                      (span-inside-any-range? span ranges))
+                    spans)))))
 
 (def (token-text-inside-python-conditional-test? text token)
   (let ([token-len (string-length token)])
diff --git a/tests/smoke.ss b/tests/smoke.ss
index c442771..65f19ac 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -1582,6 +1582,19 @@
     (check (map finding-start-line findings) => '(5 14))
     (check (map finding-start-col findings) => '(31 31))))
 
+(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"]
+         [findings
+          (scan-config-string
+            taint-config
+            "ts"
+            "demo.ts"
+            "import * as http from 'http';\n\nconst httpOptions = urlToHttpOptions(reqUrl);\nconst host: string = httpOptions.hostname;\n\nconst req: http.ClientRequest = (reqUrl.protocol.startsWith('https') ? https: http).request({\n    host, port, method, path, rejectUnauthorized: false,\n    lookup: getLookupFunction(logger, Boolean(opts.useRoundRobinDns), sanitize),\n    beforeRedirect: (opts) => {\n        // no sanitizeIP call\n    },\n})\n")])
+    (check (length findings) => 1)
+    (check (finding-start-line (car findings)) => 6)
+    (check (finding-start-col (car findings)) => 33)))
+
 (test-case "scan TypeScript taint source after trailing pattern-inside"
   (let* ([taint-config
           "rules:\n  - id: demo.taint.ts.record\n    mode: taint\n    languages: [typescript]\n    message: record taint\n    severity: WARNING\n    pattern-sources:\n      - patterns:\n          - pattern-inside: |\n              $DBQUERY.findByPk(...)\n              ...\n          - pattern: $USER.dataValues\n    pattern-sinks:\n      - patterns:\n          - pattern-inside: |\n              $PUG = require('pug')\n              ...\n          - pattern-either:\n              - pattern: $PUG.compile(...)\n"]