Advance taint control parity

ober

7beb039040f6587b83af90f9308e64bac534a146

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index 24d6ef4..cd0854a 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -65,7 +65,7 @@ make test
 Result:
 
 ```text
-176 tests, 176 passed, 0 failed
+178 tests, 178 passed, 0 failed
 ```
 
 Local oracle:
@@ -83,13 +83,13 @@ oracle: 42 passed, 0 failed
 Focused upstream cases fixed in this checkpoint:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_best_fit_sink3|taint_clean_in_try_no_finally)$' LIST_MISMATCHES=1 MAX_DIFFS=160 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_control|taint_flask|taint_imported_func)$' LIST_MISMATCHES=1 MAX_DIFFS=160 tests/oracle/upstream-sweep.sh
 ```
 
 Result:
 
 ```text
-upstream-sweep: 2 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 2 compared
+upstream-sweep: 3 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 3 compared
 ```
 
 Focused safe-option/propagation guardrail:
@@ -113,102 +113,67 @@ SEMGREP_CURRENT=/Users/user/.local/bin/semgrep MAX_CASES=220 LIST_MISMATCHES=1 M
 Result:
 
 ```text
-upstream-sweep: 183 passed, 35 mismatched, 0 jerboa errors, 2 current errors, 220 compared
+upstream-sweep: 186 passed, 32 mismatched, 0 jerboa errors, 2 current errors, 220 compared
 ```
 
 Important progress markers:
 
 - The first 160 upstream pairs are still clean for Jerboa: 158 passed,
   0 mismatched, 0 Jerboa errors, 2 packaged-Semgrep current errors.
-- The first-220 sweep improved from 37 mismatches to 35 mismatches in this
+- The first-220 sweep improved from 35 mismatches to 32 mismatches in this
   checkpoint.
-- `taint_best_fit_sink3` and `taint_clean_in_try_no_finally` are no longer part
+- `taint_control`, `taint_flask`, and `taint_imported_func` are no longer part
   of the mismatch frontier.
 - The 2 non-passing current errors in the first-160/first-220 windows are
   packaged-Semgrep oracle errors, not Jerboa scanner errors.
 
 ## What Changed In This Checkpoint
 
-The current checkpoint extends the taint best-fit/sanitizer work. It adds
-support for `taint_assume_safe_functions` in Python focused sink shapes and
-prevents later implicit assignments from resurrecting token taint after a
-compatible sanitizer has run. The source changes are in `src/semgrep/scan.ss`;
-`make test` mirrored them into `lib/semgrep/scan.sls`.
+The current checkpoint adds control-taint reachability and Python import-alias
+pattern matching for taint specs. The source changes are in
+`src/semgrep/scan.ss`; `make test` mirrored them into
+`lib/semgrep/scan.sls`.
 
 New implementation landmarks:
 
 ```text
-token-text-inside-python-conditional-test?
-token-source-inside-python-conditional-test?
-finding-range-contains-span?
-sanitizer-covers-token-span?
-token-text-fully-sanitized-in-sink?
-token-source-fully-sanitized-in-sink?
-taint-assume-safe-functions?
-call-name-start-before-open
-matching-close-paren-index
-finding-focused-to-whole-binding?
-safe-function-wrapper-in-text?
-taint-safe-function-use?
+taint-state-control?
+python-import-local-pattern-candidates
+scan-python-import-local-pattern
+scan-python-import-local-pattern-with-bindings
 ```
 
 Behavior added:
 
-- Bare-token taint sources are suppressed when the only relevant use in a
-  Python conditional expression is inside the conditional test, e.g.
-  `sink(safe if isset(source) else '')`.
-- Nested sanitizer calls now block token taint when the sanitizer is contained
-  inside the sink and covers the token occurrence, e.g.
-  `sink(sanitizer(source) if isset(safe) else '')`.
-- Token sinks are treated occurrence-by-occurrence for sanitizer coverage:
-  a sink is suppressed only when every occurrence of the token in the sink is
-  covered by a sanitizer. This avoids over-generalizing to mixed shapes like
-  `sink(sanitizer(source), source)`.
-- Non-exact sink reach was tightened for token states so a later same-named
-  token source cannot taint an earlier sink merely because the text matches.
-  Token sources now still need to be before the sink or physically contained in
-  it when the non-exact sink branch is used.
-- With `taint_assume_safe_functions: true`, taint nested under an ordinary
-  function result is suppressed, e.g. `sink(not_a_propagator(tainted()))`,
-  while direct sink arguments such as `sink(tainted())` still report.
-- The safe-function check handles focused sink findings by recognizing when the
-  finding range is exactly the focused metavariable binding.
-- Propagation through later implicit assignments is blocked when the source was
-  sanitized before the assignment RHS, so `id = int(id)` prevents later
-  `some_object = id` / `csv_file = ...` propagation from reviving the old
-  `request` taint.
-
-Existing sanitizer logic changed:
-
-- In `sanitizer-blocks?`, the nested-token branch now checks whether the sink
-  contains the sanitizer, instead of incorrectly checking whether the sanitizer
-  starts before the sink. This matters for `sink(sanitizer(source))` shapes.
+- `control: true` taint sources now carry a control bit in the taint state and
+  can reach later sinks in the same simple function scope by order/control
+  reachability rather than value containment.
+- The control bit is preserved through label changes and participates in
+  taint-state identity, so label/requires logic does not merge control and
+  ordinary value taint states.
+- Python patterns written with imported fully qualified names can match local
+  imported calls. For example, a rule pattern
+  `framework.db_access.mysql_update($SINK)` now matches target code after
+  `from framework import db_access` as `db_access.mysql_update(params)`.
+- The Python import-local fallback returns `#f` when it has no findings, so it
+  does not short-circuit later structural/symbolic matchers in fallback `or`
+  chains.
+- Clean reassignment killing now applies to any source whose source text equals
+  the assigned LHS, not only token-marked states. This helps focused
+  function-parameter sources and variable-like source patterns.
 
 Smoke coverage added:
 
 ```text
-scan taint sanitizer blocks conditional sink token
-scan taint assume safe functions blocks wrapper
-scan taint sanitizer blocks later assignment propagation
-```
-
-That smoke test mirrors upstream `taint_best_fit_sink2.py`:
-
-```python
-sink(sanitizer(source) if isset(source) else '')
-sink(sanitizer(source) if isset(safe) else '')
-sink(sanitizer(safe) if isset(source) else '')
-sink(safe if isset(source) else '')
+scan taint control source reaches later sinks
+scan Python taint imported fully qualified sink
 ```
 
-Expected result: zero findings.
-
 ## Current Upstream Frontier
 
-The first-220 sweep currently has these 35 Jerboa mismatches:
+The first-220 sweep currently has these 32 Jerboa mismatches:
 
 ```text
-taint_control
 taint_exact_sources
 taint_exception
 taint_field_sensitive1
@@ -219,8 +184,6 @@ taint_field_sensitive6
 taint_field_sensitive7
 taint_field_sensitive8
 taint_final_globals1
-taint_flask
-taint_imported_func
 taint_labels2
 taint_labels3
 taint_lambda2
@@ -245,76 +208,38 @@ taint_react
 taint_safe_comparisons
 ```
 
-The next case to investigate is `taint_control`.
+The next case to investigate is `taint_exact_sources`.
 
 Focused command:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_control$' LIST_MISMATCHES=1 MAX_DIFFS=200 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_exact_sources$' LIST_MISMATCHES=1 MAX_DIFFS=240 tests/oracle/upstream-sweep.sh
 ```
 
 Current normalized diff:
 
 ```diff
-@@ -1,3 +0,0 @@
--(finding "test" "/Users/user/mine/semgrep/tests/rules/taint_control.py" 14 5 140 14 11 146 "WARNING" "Test" "")
--(finding "test" "/Users/user/mine/semgrep/tests/rules/taint_control.py" 22 5 228 22 11 234 "WARNING" "Test" "")
--(finding "test" "/Users/user/mine/semgrep/tests/rules/taint_control.py" 6 3 58 6 9 64 "WARNING" "Test" "")
-```
-
-Rule:
-
-```yaml
-rules:
-  - id: test
-    languages:
-      - python
-    severity: WARNING
-    mode: taint
-    message: Test
-    pattern-sources:
-      - control: true
-        pattern: source(...)
-    pattern-sinks:
-      - pattern: sink(...)
-```
-
-Target:
-
-```python
-def test1():
-  source()
-  foo()
-  bar()
-  #ruleid: test
-  sink()
-
-def test2():
-  source()
-  foo()
-  bar()
-  if baz():
-    #ruleid: test
-    sink()
-
-def test3():
-  if foo():
-    source()
-  bar()
-  if baz():
-    #ruleid: test
-    sink()
+@@ -1,5 +1,7 @@
++(finding "sql-injection" "/Users/user/mine/semgrep/tests/rules/taint_exact_sources.py" 23 36 776 23 42 782 "WARNING" "Semgrep found a match" "")
+ (finding "sql-injection" "/Users/user/mine/semgrep/tests/rules/taint_exact_sources.py" 28 28 990 28 34 996 "WARNING" "Semgrep found a match" "")
+ (finding "sql-injection" "/Users/user/mine/semgrep/tests/rules/taint_exact_sources.py" 33 32 1161 33 38 1167 "WARNING" "Semgrep found a match" "")
++(finding "sql-injection" "/Users/user/mine/semgrep/tests/rules/taint_exact_sources.py" 41 28 1483 41 34 1489 "WARNING" "Semgrep found a match" "")
+ (finding "sql-injection" "/Users/user/mine/semgrep/tests/rules/taint_exact_sources.py" 49 28 1654 49 34 1660 "WARNING" "Semgrep found a match" "")
+ (finding "sql-injection" "/Users/user/mine/semgrep/tests/rules/taint_exact_sources.py" 60 28 2029 60 34 2035 "WARNING" "Semgrep found a match" "")
+ (finding "sql-injection" "/Users/user/mine/semgrep/tests/rules/taint_exact_sources.py" 69 28 2232 69 34 2238 "WARNING" "Semgrep found a match" "")
 ```
 
 Interpretation for the next fix:
 
-- Jerboa currently reports zero findings for control-taint sources.
-- Semgrep reports all three sinks after a `control: true` source in the same
-  function/control region.
-- The parser already records `control` on taint source specs; the next patch
-  should inspect `taint-state-for-spec`, source reachability, and label
-  propagation to model control taint without making ordinary value taint leak
-  across unrelated functions.
+- The Python import-local fallback moved this case from all-missing findings to
+  mostly matching the expected sink family.
+- Remaining false positives are line 23 (`results = db_access.mysql_dict(params)`)
+  after a clean assignment to `params["sql"]`, and line 41 after a clean
+  assignment to `params["name"]`.
+- Continue in the assignment-kill / field-sensitivity area. The broad
+  source-text-equals-LHS kill is not enough because later self-use and indexed
+  assignment semantics still need to distinguish clean field writes from
+  tainting writes.
 
 ## Resolved First-160 Taint Cluster
 
@@ -484,8 +409,9 @@ Use the target extension that actually exists for the case (`.py`, `.js`,
 
 ## Immediate Next Step
 
-Start with `taint_control`. Add a smoke test for `control: true` sources where
-`source()` causes later `sink()` calls in the same function to report, including
-inside a later `if` block. Then patch taint reachability so control-taint flows
-through control/order within the same simple function scope without changing
-ordinary value-taint behavior.
+Start with `taint_exact_sources`. The remaining work is not import matching;
+that now works. Focus on suppressing the two false positives at lines 23 and
+41 while preserving the expected reports at lines 28, 33, 49, 60, and 69.
+Likely areas are assignment-kill ordering, indexed-field sensitivity, and
+whether clean indexed assignments should block the original focused parameter
+source until a later genuinely tainted write re-taints the container.
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index 24c0717..e4aee34 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -2799,6 +2799,70 @@
            (string=? language "python2")
            (string=? language "python3")
            (string=? language "py")))
+  (def (replace-imported-fqn-pattern pattern local fqn)
+       (let ([fqn-len (string-length fqn)])
+         (let loop ([start 0])
+           (let ([index (string-find-substring-from
+                          pattern
+                          fqn
+                          start)])
+             (and index
+                  (let ([end (+ index fqn-len)])
+                    (if (and (token-start-boundary? pattern index)
+                             (token-end-boundary? pattern end))
+                        (replace-range pattern index end local)
+                        (loop (+ index 1)))))))))
+  (def (python-import-local-pattern-candidates source pattern)
+       (unique-string-list
+         (let import-loop ([imports (python-import-map source)]
+                           [acc '()])
+           (if (null? imports)
+               acc
+               (let* ([entry (car imports)]
+                      [local (car entry)]
+                      [fqns (cdr entry)])
+                 (import-loop
+                   (cdr imports)
+                   (let fqn-loop ([xs fqns] [inner acc])
+                     (if (null? xs)
+                         inner
+                         (fqn-loop
+                           (cdr xs)
+                           (cons
+                             (replace-imported-fqn-pattern
+                               pattern
+                               local
+                               (car xs))
+                             inner))))))))))
+  (def (scan-python-import-local-pattern rule language path
+         source target-root pattern)
+       (and (symbolic-python-like-language? language)
+            (let ([patterns (python-import-local-pattern-candidates
+                              source
+                              pattern)])
+              (and (not (null? patterns))
+                   (let ([findings (apply
+                                     append
+                                     (map (lambda (candidate)
+                                            (scan-structural-pattern rule language path source
+                                              target-root candidate))
+                                          patterns))])
+                     (and (not (null? findings)) findings))))))
+  (def (scan-python-import-local-pattern-with-bindings rule language path source target-root pattern
+         initial-bindings)
+       (and (symbolic-python-like-language? language)
+            (let ([patterns (python-import-local-pattern-candidates
+                              source
+                              pattern)])
+              (and (not (null? patterns))
+                   (let ([findings (apply
+                                     append
+                                     (map (lambda (candidate)
+                                            (scan-structural-pattern-with-bindings rule language path source
+                                              target-root candidate
+                                              initial-bindings))
+                                          patterns))])
+                     (and (not (null? findings)) findings))))))
   (def (symbolic-assignment-line-offset source start)
        (if (and (< start (string-length source))
                 (char=? (string-ref source start) #\newline))
@@ -4199,6 +4263,8 @@
                      path
                      source
                      (cdr entry)))
+              (scan-python-import-local-pattern rule language path source
+                target-root (cdr entry))
               (scan-symbolic-propagation-pattern rule language path source
                 (cdr entry))
               (scan-structural-pattern rule language path source
@@ -4373,6 +4439,8 @@
                      path
                      source
                      (cdr entry)))
+              (scan-python-import-local-pattern-with-bindings rule language path source target-root (cdr entry)
+                initial-bindings)
               (scan-symbolic-propagation-pattern rule language path source
                 (cdr entry))
               (scan-structural-pattern-with-bindings rule language path source target-root (cdr entry)
@@ -7622,10 +7690,11 @@
          target-root)
        (scan-patterns-clauses rule (rule-pattern rule) language
          path source target-root))
-  (def (taint-state finding labels exact? token? contained?)
+  (def (taint-state finding labels exact? token? contained?
+         control?)
        (list (cons 'finding finding) (cons 'labels labels)
          (cons 'exact exact?) (cons 'token token?)
-         (cons 'contained contained?)))
+         (cons 'contained contained?) (cons 'control control?)))
   (def (taint-state-finding state)
        (alist-ref/default state 'finding #f))
   (def (taint-state-labels state)
@@ -7636,10 +7705,13 @@
        (and (alist-ref/default state 'token #f) #t))
   (def (taint-state-contained? state)
        (and (alist-ref/default state 'contained #f) #t))
+  (def (taint-state-control? state)
+       (and (alist-ref/default state 'control #f) #t))
   (def (taint-state-with-labels state labels)
        (taint-state (taint-state-finding state) labels
          (taint-state-exact? state) (taint-state-token? state)
-         (taint-state-contained? state)))
+         (taint-state-contained? state)
+         (taint-state-control? state)))
   (def (taint-by-side-effect? value)
        (cond
          [(not value) #f]
@@ -7695,7 +7767,7 @@
            (or side-effect?
                (taint-function-parameter-source-spec? spec)
                (taint-bare-identifier-source-spec? spec))
-           #f)))
+           #f (alist-ref/default spec 'control #f))))
   (def (scan-taint-specs rule specs language path source
          target-root default-label)
        (apply
@@ -7973,24 +8045,29 @@
          source-text)
        (let ([source (taint-state-finding source-state)])
          (and source
-              (if (taint-state-exact? source-state)
-                  (finding-range-equal? source sink)
-                  (or (finding-range-contains? sink source)
-                      (and (taint-state-contained? source-state)
-                           (finding-range-contains? sink source))
-                      (finding-text-equals-any-binding?
-                        source
-                        sink
-                        source-text)
-                      (finding-text-equals-any-binding?
-                        sink
-                        source
-                        source-text)
-                      (and (taint-state-token? source-state)
-                           (source-token-in-sink? source sink source-text))
-                      (and (null? (finding-metavars source))
-                           (null? (finding-metavars sink))
-                           (finding-range-contains? sink source)))))))
+              (if (taint-state-control? source-state)
+                  #t
+                  (if (taint-state-exact? source-state)
+                      (finding-range-equal? source sink)
+                      (or (finding-range-contains? sink source)
+                          (and (taint-state-contained? source-state)
+                               (finding-range-contains? sink source))
+                          (finding-text-equals-any-binding?
+                            source
+                            sink
+                            source-text)
+                          (finding-text-equals-any-binding?
+                            sink
+                            source
+                            source-text)
+                          (and (taint-state-token? source-state)
+                               (source-token-in-sink?
+                                 source
+                                 sink
+                                 source-text))
+                          (and (null? (finding-metavars source))
+                               (null? (finding-metavars sink))
+                               (finding-range-contains? sink source))))))))
   (def (taint-assume-safe-booleans? rule)
        (rule-option-enabled? rule "taint_assume_safe_booleans"))
   (def (taint-assume-safe-numbers? rule)
@@ -8426,8 +8503,7 @@
                                (finding-metavariable-binding
                                  assignment-finding
                                  "$L"))])
-         (and (taint-state-token? source-state)
-              source
+         (and source
               assignment-finding
               sink
               to-binding
@@ -8753,6 +8829,8 @@
                      (taint-state-token? existing))
                 (eq? (taint-state-contained? state)
                      (taint-state-contained? existing))
+                (eq? (taint-state-control? state)
+                     (taint-state-control? existing))
                 (same-label-set?
                   (taint-state-labels state)
                   (taint-state-labels existing))))
@@ -8826,7 +8904,7 @@
          (and merged
               (taint-state (finding-with-bindings rule focused merged source)
                 (propagator-output-labels source-state propagator) #f
-                side-effect? (not side-effect?)))))
+                side-effect? (not side-effect?) #f))))
   (def (new-required-sources source-matches known source)
        (let loop ([matches source-matches] [acc '()])
          (if (null? matches)
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index 4f1d910..af3882a 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" . "E1CA04F548FE150")
-  ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
-  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
+  ("src/semgrep/scan.ss" . "471D16BC922EC36F")
   ("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 b719d25..a7444cd 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -2927,6 +2927,86 @@
       (string=? language "python3")
       (string=? language "py")))
 
+(def (replace-imported-fqn-pattern pattern local fqn)
+  (let ([fqn-len (string-length fqn)])
+    (let loop ([start 0])
+      (let ([index (string-find-substring-from pattern fqn start)])
+        (and index
+             (let ([end (+ index fqn-len)])
+               (if (and (token-start-boundary? pattern index)
+                        (token-end-boundary? pattern end))
+                   (replace-range pattern index end local)
+                   (loop (+ index 1)))))))))
+
+(def (python-import-local-pattern-candidates source pattern)
+  (unique-string-list
+    (let import-loop ([imports (python-import-map source)] [acc '()])
+      (if (null? imports)
+          acc
+          (let* ([entry (car imports)]
+                 [local (car entry)]
+                 [fqns (cdr entry)])
+            (import-loop
+              (cdr imports)
+              (let fqn-loop ([xs fqns] [inner acc])
+                (if (null? xs)
+                    inner
+                    (fqn-loop
+                      (cdr xs)
+                      (cons (replace-imported-fqn-pattern
+                              pattern
+                              local
+                              (car xs))
+                            inner))))))))))
+
+(def (scan-python-import-local-pattern
+       rule
+       language
+       path
+       source
+       target-root
+       pattern)
+  (and (symbolic-python-like-language? language)
+       (let ([patterns (python-import-local-pattern-candidates source pattern)])
+         (and (not (null? patterns))
+              (let ([findings
+                     (apply append
+                            (map (lambda (candidate)
+                                   (scan-structural-pattern
+                                     rule
+                                     language
+                                     path
+                                     source
+                                     target-root
+                                     candidate))
+                                 patterns))])
+                (and (not (null? findings)) findings))))))
+
+(def (scan-python-import-local-pattern-with-bindings
+       rule
+       language
+       path
+       source
+       target-root
+       pattern
+       initial-bindings)
+  (and (symbolic-python-like-language? language)
+       (let ([patterns (python-import-local-pattern-candidates source pattern)])
+         (and (not (null? patterns))
+              (let ([findings
+                     (apply append
+                            (map (lambda (candidate)
+                                   (scan-structural-pattern-with-bindings
+                                     rule
+                                     language
+                                     path
+                                     source
+                                     target-root
+                                     candidate
+                                     initial-bindings))
+                                 patterns))])
+                (and (not (null? findings)) findings))))))
+
 (def (symbolic-assignment-line-offset source start)
   (if (and (< start (string-length source))
            (char=? (string-ref source start) #\newline))
@@ -4359,6 +4439,13 @@
                 path
                 source
                 (cdr entry)))
+         (scan-python-import-local-pattern
+           rule
+           language
+           path
+           source
+           target-root
+           (cdr entry))
          (scan-symbolic-propagation-pattern
            rule
            language
@@ -4590,6 +4677,14 @@
                 path
                 source
                 (cdr entry)))
+         (scan-python-import-local-pattern-with-bindings
+           rule
+           language
+           path
+           source
+           target-root
+           (cdr entry)
+           initial-bindings)
          (scan-symbolic-propagation-pattern
            rule
            language
@@ -8068,12 +8163,13 @@
                          source
                          target-root))
 
-(def (taint-state finding labels exact? token? contained?)
+(def (taint-state finding labels exact? token? contained? control?)
   (list (cons 'finding finding)
         (cons 'labels labels)
         (cons 'exact exact?)
         (cons 'token token?)
-        (cons 'contained contained?)))
+        (cons 'contained contained?)
+        (cons 'control control?)))
 
 (def (taint-state-finding state)
   (alist-ref/default state 'finding #f))
@@ -8090,13 +8186,17 @@
 (def (taint-state-contained? state)
   (and (alist-ref/default state 'contained #f) #t))
 
+(def (taint-state-control? state)
+  (and (alist-ref/default state 'control #f) #t))
+
 (def (taint-state-with-labels state labels)
   (taint-state
     (taint-state-finding state)
     labels
     (taint-state-exact? state)
     (taint-state-token? state)
-    (taint-state-contained? state)))
+    (taint-state-contained? state)
+    (taint-state-control? state)))
 
 (def (taint-by-side-effect? value)
   (cond
@@ -8163,7 +8263,8 @@
       (or side-effect?
           (taint-function-parameter-source-spec? spec)
           (taint-bare-identifier-source-spec? spec))
-      #f)))
+      #f
+      (alist-ref/default spec 'control #f))))
 
 (def (scan-taint-specs rule specs language path source target-root default-label)
   (apply append
@@ -8477,18 +8578,20 @@
 (def (source-compatible-with-sink? source-state sink source-text)
   (let ([source (taint-state-finding source-state)])
     (and source
-         (if (taint-state-exact? source-state)
-             (finding-range-equal? source sink)
-             (or (finding-range-contains? sink source)
-                 (and (taint-state-contained? source-state)
-                      (finding-range-contains? sink source))
-                 (finding-text-equals-any-binding? source sink source-text)
-                 (finding-text-equals-any-binding? sink source source-text)
-                 (and (taint-state-token? source-state)
-                      (source-token-in-sink? source sink source-text))
-                 (and (null? (finding-metavars source))
-                      (null? (finding-metavars sink))
-                      (finding-range-contains? sink source)))))))
+         (if (taint-state-control? source-state)
+             #t
+             (if (taint-state-exact? source-state)
+                 (finding-range-equal? source sink)
+                 (or (finding-range-contains? sink source)
+                     (and (taint-state-contained? source-state)
+                          (finding-range-contains? sink source))
+                     (finding-text-equals-any-binding? source sink source-text)
+                     (finding-text-equals-any-binding? sink source source-text)
+                     (and (taint-state-token? source-state)
+                          (source-token-in-sink? source sink source-text))
+                     (and (null? (finding-metavars source))
+                          (null? (finding-metavars sink))
+                          (finding-range-contains? sink source))))))))
 
 (def (taint-assume-safe-booleans? rule)
   (rule-option-enabled? rule "taint_assume_safe_booleans"))
@@ -8879,8 +8982,7 @@
                           (finding-metavariable-binding
                             assignment-finding
                             "$L"))])
-    (and (taint-state-token? source-state)
-         source
+    (and source
          assignment-finding
          sink
          to-binding
@@ -9202,6 +9304,8 @@
                     (taint-state-token? existing))
                (eq? (taint-state-contained? state)
                     (taint-state-contained? existing))
+               (eq? (taint-state-control? state)
+                    (taint-state-control? existing))
                (same-label-set? (taint-state-labels state)
                                 (taint-state-labels existing))))
         states))
@@ -9271,7 +9375,8 @@
            (propagator-output-labels source-state propagator)
            #f
            side-effect?
-           (not side-effect?)))))
+           (not side-effect?)
+           #f))))
 
 (def (new-required-sources source-matches known source)
   (let loop ([matches source-matches] [acc '()])
diff --git a/tests/smoke.ss b/tests/smoke.ss
index 5b6b41c..5df6924 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -1424,6 +1424,33 @@
     (check (length findings) => 1)
     (check (finding-start-line (car findings)) => 1)))
 
+(test-case "scan taint control source reaches later sinks"
+  (let* ([taint-config
+          "rules:\n  - id: demo.taint.control\n    mode: taint\n    languages: [python]\n    message: control taint\n    severity: WARNING\n    pattern-sources:\n      - control: true\n        pattern: source(...)\n    pattern-sinks:\n      - pattern: sink(...)\n"]
+         [findings
+          (scan-config-string
+            taint-config
+            "python"
+            "demo.py"
+            "def test1():\n  source()\n  foo()\n  bar()\n  sink()\n\ndef test2():\n  source()\n  foo()\n  bar()\n  if baz():\n    sink()\n\ndef test3():\n  if foo():\n    source()\n  bar()\n  if baz():\n    sink()\n")])
+    (check (length findings) => 3)
+    (check (finding-start-line (car findings)) => 5)
+    (check (finding-start-line (cadr findings)) => 12)
+    (check (finding-start-line (caddr findings)) => 19)))
+
+(test-case "scan Python taint imported fully qualified sink"
+  (let* ([taint-config
+          "rules:\n  - id: demo.taint.python-imported-sink\n    mode: taint\n    languages: [python]\n    message: imported sink\n    severity: WARNING\n    pattern-sources:\n      - patterns:\n          - pattern: |\n              def $FN(..., $PARAMS, ...):\n                ...\n          - focus-metavariable: $PARAMS\n    pattern-sinks:\n      - patterns:\n          - pattern: framework.db_access.mysql_update($SINK)\n          - focus-metavariable: $SINK\n"]
+         [findings
+          (scan-config-string
+            taint-config
+            "python"
+            "demo.py"
+            "from framework import db_access\n\ndef fn(params):\n    db_access.mysql_update(params)\n")])
+    (check (length findings) => 1)
+    (check (finding-start-line (car findings)) => 4)
+    (check (finding-start-col (car findings)) => 28)))
+
 (test-case "scan taint implicit assignment propagation"
   (let* ([taint-config
           "rules:\n  - id: demo.taint.assignment\n    mode: taint\n    languages: [python]\n    message: assigned taint\n    severity: WARNING\n    pattern-sources:\n      - pattern: source()\n    pattern-sinks:\n      - pattern: sink($X)\n"]