diff --git a/docs/security_analysis/custom_taint_sources.md b/docs/security_analysis/custom_taint_sources.md
index d3bdb0a3205..938bd3455ba 100644
--- a/docs/security_analysis/custom_taint_sources.md
+++ b/docs/security_analysis/custom_taint_sources.md
@@ -23,52 +23,32 @@ For example this plugin treats all variables named `$bad_data` as taint sources.
 ```php
 <?php
 
-namespace Some\Ns;
+namespace Psalm\Example\Plugin;
 
-use PhpParser;
-use Psalm\CodeLocation;
-use Psalm\Context;
-use Psalm\FileManipulation;
-use Psalm\Plugin\EventHandler\AfterExpressionAnalysisInterface;
-use Psalm\Plugin\EventHandler\Event\AfterExpressionAnalysisEvent;
+use PhpParser\Node\Expr\Variable;
+use Psalm\Plugin\EventHandler\AddTaintsInterface;
+use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent;
 use Psalm\Type\TaintKindGroup;
 
-class BadSqlTainter implements AfterExpressionAnalysisInterface
+/**
+ * Add input taints to all variables named 'bad_data'
+ */
+class TaintBadDataPlugin implements AddTaintsInterface
 {
     /**
-     * Called after an expression has been checked
-     *
-     * @param  PhpParser\Node\Expr  $expr
-     * @param  Context              $context
-     * @param  FileManipulation[]   $file_replacements
+     * Called to see what taints should be added
      *
-     * @return void
+     * @return list<string>
      */
-    public static function afterExpressionAnalysis(AfterExpressionAnalysisEvent $event): ?bool {
+    public static function addTaints(AddRemoveTaintsEvent $event): array
+    {
         $expr = $event->getExpr();
-        $statements_source = $event->getStatementsSource();
-        $codebase = $event->getCodebase();
-        if ($expr instanceof PhpParser\Node\Expr\Variable
-            && $expr->name === 'bad_data'
-        ) {
-            $expr_type = $statements_source->getNodeTypeProvider()->getType($expr);
 
-            // should be a globally unique id
-            // you can use its line number/start offset
-            $expr_identifier = '$bad_data'
-                . '-' . $statements_source->getFileName()
-                . ':' . $expr->getAttribute('startFilePos');
-
-            if ($expr_type) {
-                $codebase->addTaintSource(
-                    $expr_type,
-                    $expr_identifier,
-                    TaintKindGroup::ALL_INPUT,
-                    new CodeLocation($statements_source, $expr)
-                );
-            }
+        if ($expr instanceof Variable && $expr->name === 'bad_data') {
+            return TaintKindGroup::ALL_INPUT;
         }
-        return null;
+
+        return [];
     }
 }
 ```
diff --git a/examples/plugins/TaintActiveRecords.php b/examples/plugins/TaintActiveRecords.php
new file mode 100644
index 00000000000..c8aa9229cd3
--- /dev/null
+++ b/examples/plugins/TaintActiveRecords.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Psalm\Example\Plugin;
+
+use PhpParser\Node\ArrayItem;
+use PhpParser\Node\Expr\PropertyFetch;
+use PhpParser\Node\Expr;
+use Psalm\Plugin\EventHandler\AddTaintsInterface;
+use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent;
+use Psalm\Type\Atomic;
+use Psalm\Type\Atomic\TNamedObject;
+use Psalm\Type\TaintKindGroup;
+use Psalm\Type\Union;
+
+/**
+ * Marks all property fetches of models inside namespace \app\models as tainted.
+ * ActiveRecords are model-representation of database entries, which can always
+ * contain user-input and therefor should be tainted.
+ */
+class TaintActiveRecords implements AddTaintsInterface
+{
+    /**
+     * Called to see what taints should be added
+     *
+     * @return list<string>
+     */
+    public static function addTaints(AddRemoveTaintsEvent $event): array
+    {
+        $expr = $event->getExpr();
+
+        // Model properties are accessed by property fetch, so abort here
+        if ($expr instanceof ArrayItem) {
+            return [];
+        }
+
+        $statements_source = $event->getStatementsSource();
+
+        // For all property fetch expressions, walk through the full fetch path
+        // (e.g. `$model->property->subproperty`) and check if it contains
+        // any class of namespace \app\models\
+        do {
+            $expr_type = $statements_source->getNodeTypeProvider()->getType($expr);
+            if (!$expr_type) {
+                continue;
+            }
+
+            if (self::containsActiveRecord($expr_type)) {
+                return TaintKindGroup::ALL_INPUT;
+            }
+        } while ($expr = self::getParentNode($expr));
+
+        return [];
+    }
+
+    /**
+     * @return bool `true` if union contains a type of model
+     */
+    private static function containsActiveRecord(Union $union_type): bool
+    {
+        foreach ($union_type->getAtomicTypes() as $type) {
+            if (self::isActiveRecord($type)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * @return bool `true` if namespace of type is in namespace `app\models`
+     */
+    private static function isActiveRecord(Atomic $type): bool
+    {
+        if (!$type instanceof TNamedObject) {
+            return false;
+        }
+
+        return strpos($type->value, 'app\models\\') === 0;
+    }
+
+
+    /**
+     * Return next node that should be followed for active record search
+     */
+    private static function getParentNode(ArrayItem|Expr $expr): ?Expr
+    {
+        // Model properties are always accessed by a property fetch
+        if ($expr instanceof PropertyFetch) {
+            return $expr->var;
+        }
+
+        return null;
+    }
+}
diff --git a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php
index 502b9504ec7..7ff370f00c5 100644
--- a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php
@@ -17,6 +17,7 @@
 use Psalm\FileManipulation;
 use Psalm\Internal\Analyzer\FunctionLike\ReturnTypeAnalyzer;
 use Psalm\Internal\Analyzer\FunctionLike\ReturnTypeCollector;
+use Psalm\Internal\Analyzer\Statements\Expression\Call\FunctionCallReturnTypeFetcher;
 use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer;
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\Codebase\VariableUseGraph;
@@ -830,6 +831,24 @@ public function analyze(
             }
         }
 
+        // Class methods are analyzed deferred, therefor it's required to
+        // add taint sources additionally on analyze not only on call
+        if ($codebase->taint_flow_graph
+            && $this->function instanceof ClassMethod
+            && $cased_method_id) {
+            $method_source = DataFlowNode::getForMethodReturn(
+                (string) $method_id,
+                $cased_method_id,
+                $storage->location,
+            );
+
+            FunctionCallReturnTypeFetcher::taintUsingStorage(
+                $storage,
+                $codebase->taint_flow_graph,
+                $method_source,
+            );
+        }
+
         if ($add_mutations) {
             if ($this->return_vars_in_scope !== null) {
                 $context->vars_in_scope = TypeAnalyzer::combineKeyedTypes(
@@ -1067,7 +1086,7 @@ private function processParams(
 
                     $statements_analyzer->data_flow_graph->addNode($param_assignment);
 
-                    if ($cased_method_id) {
+                    if ($cased_method_id !== null) {
                         $type_source = DataFlowNode::getForMethodArgument(
                             $cased_method_id,
                             $cased_method_id,
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php
index d6bc276e025..0afd08d1eab 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php
@@ -14,6 +14,7 @@
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\Codebase\VariableUseGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\Type\Comparator\UnionTypeComparator;
 use Psalm\Internal\Type\TypeCombiner;
 use Psalm\Issue\DuplicateArrayKey;
@@ -42,6 +43,7 @@
 use Psalm\Type\Atomic\TTrue;
 use Psalm\Type\Union;
 
+use function array_diff;
 use function array_merge;
 use function array_values;
 use function count;
@@ -441,6 +443,12 @@ private static function analyzeArrayItem(
                     $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
                     $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+                    $taints = array_diff($added_taints, $removed_taints);
+                    if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+                        $taint_source = TaintSource::fromNode($new_parent_node);
+                        $statements_analyzer->data_flow_graph->addSource($taint_source);
+                    }
+
                     foreach ($item_value_type->parent_nodes as $parent_node) {
                         $data_flow_graph->addPath(
                             $parent_node,
@@ -476,6 +484,13 @@ private static function analyzeArrayItem(
                     $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
                     $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+                    $taints = array_diff($added_taints, $removed_taints);
+                    if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+                        $taint_source = TaintSource::fromNode($new_parent_node);
+                        $taint_source->taints = $taints;
+                        $statements_analyzer->data_flow_graph->addSource($taint_source);
+                    }
+
                     foreach ($item_key_type->parent_nodes as $parent_node) {
                         $data_flow_graph->addPath(
                             $parent_node,
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php
index 1b061a3cbd1..0c48e9146d9 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php
@@ -28,6 +28,7 @@
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\Codebase\VariableUseGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\FileManipulation\FileManipulationBuffer;
 use Psalm\Internal\MethodIdentifier;
 use Psalm\Internal\Type\Comparator\TypeComparisonResult;
@@ -78,6 +79,7 @@
 use Psalm\Type\Union;
 use UnexpectedValueException;
 
+use function array_diff;
 use function array_merge;
 use function array_pop;
 use function count;
@@ -505,6 +507,13 @@ private static function taintProperty(
                 $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
                 $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+                $taints = array_diff($added_taints, $removed_taints);
+                if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+                    $taint_source = TaintSource::fromNode($property_node);
+                    $taint_source->taints = $taints;
+                    $statements_analyzer->data_flow_graph->addSource($taint_source);
+                }
+
                 $data_flow_graph->addPath(
                     $property_node,
                     $var_node,
@@ -600,6 +609,13 @@ public static function taintUnspecializedProperty(
         $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
         $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+        $taints = array_diff($added_taints, $removed_taints);
+        if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+            $taint_source = TaintSource::fromNode($property_node);
+            $taint_source->taints = $taints;
+            $statements_analyzer->data_flow_graph->addSource($taint_source);
+        }
+
         $data_flow_graph->addPath(
             $localized_property_node,
             $property_node,
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php
index b0815c020a9..291d95edea7 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php
@@ -33,6 +33,7 @@
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\Codebase\VariableUseGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\FileManipulation\FileManipulationBuffer;
 use Psalm\Internal\ReferenceConstraint;
 use Psalm\Internal\Scanner\VarDocblockComment;
@@ -87,6 +88,7 @@
 use Psalm\Type\Union;
 use UnexpectedValueException;
 
+use function array_diff;
 use function count;
 use function in_array;
 use function is_string;
@@ -152,60 +154,20 @@ public static function analyze(
 
         $removed_taints = [];
 
-        if ($doc_comment) {
-            $file_path = $statements_analyzer->getRootFilePath();
-
-            $file_storage_provider = $codebase->file_storage_provider;
-
-            $file_storage = $file_storage_provider->get($file_path);
-
-            $template_type_map = $statements_analyzer->getTemplateTypeMap();
-
-            try {
-                $var_comments = $codebase->config->disable_var_parsing
-                    ? []
-                    : CommentAnalyzer::getTypeFromComment(
-                        $doc_comment,
-                        $statements_analyzer->getSource(),
-                        $statements_analyzer->getAliases(),
-                        $template_type_map,
-                        $file_storage->type_aliases,
-                    );
-            } catch (IncorrectDocblockException $e) {
-                IssueBuffer::maybeAdd(
-                    new MissingDocblockType(
-                        $e->getMessage(),
-                        new CodeLocation($statements_analyzer->getSource(), $assign_var),
-                    ),
-                );
-            } catch (DocblockParseException $e) {
-                IssueBuffer::maybeAdd(
-                    new InvalidDocblock(
-                        $e->getMessage(),
-                        new CodeLocation($statements_analyzer->getSource(), $assign_var),
-                    ),
-                );
-            }
-
-            foreach ($var_comments as $var_comment) {
-                if ($var_comment->removed_taints) {
-                    $removed_taints = $var_comment->removed_taints;
-                }
-
-                self::assignTypeFromVarDocblock(
-                    $statements_analyzer,
-                    $assign_var,
-                    $var_comment,
-                    $context,
-                    $var_id,
-                    $comment_type,
-                    $comment_type_location,
-                    $not_ignored_docblock_var_ids,
-                    $var_id === $var_comment->var_id
-                        && $assign_value_type && $comment_type && $assign_value_type->by_ref,
-                );
-            }
-        }
+        self::analyzeDocComment(
+            $statements_analyzer,
+            $codebase,
+            $context,
+            $assign_var,
+            $var_id,
+            $assign_value_type,
+            $doc_comment,
+            $var_comments,
+            $comment_type,
+            $comment_type_location,
+            $not_ignored_docblock_var_ids,
+            $removed_taints,
+        );
 
         if ($extended_var_id) {
             unset($context->cond_referenced_var_ids[$extended_var_id]);
@@ -314,28 +276,13 @@ public static function analyze(
         if ($statements_analyzer->data_flow_graph instanceof VariableUseGraph
             && !$assign_value_type->parent_nodes
         ) {
-            if ($extended_var_id) {
-                $assignment_node = DataFlowNode::getForAssignment(
-                    $extended_var_id,
-                    new CodeLocation($statements_analyzer->getSource(), $assign_var),
-                );
-            } else {
-                $assignment_node = new DataFlowNode('unknown-origin', 'unknown origin', null);
-            }
-
-            $parent_nodes = [
-                $assignment_node->id => $assignment_node,
-            ];
-
-            if ($context->inside_try) {
-                // Copy previous assignment's parent nodes inside a try. Since an exception could be thrown at any
-                // point this is a workaround to ensure that use of a variable also uses all previous assignments.
-                if (isset($context->vars_in_scope[$extended_var_id])) {
-                    $parent_nodes += $context->vars_in_scope[$extended_var_id]->parent_nodes;
-                }
-            }
-
-            $assign_value_type = $assign_value_type->setParentNodes($parent_nodes);
+            $assign_value_type = self::analyzeVariableUse(
+                $statements_analyzer,
+                $assign_var,
+                $extended_var_id,
+                $assign_value_type,
+                $context,
+            );
         }
 
         if ($extended_var_id && isset($context->vars_in_scope[$extended_var_id])) {
@@ -380,8 +327,6 @@ public static function analyze(
             }
         }
 
-        $codebase = $statements_analyzer->getCodebase();
-
         if ($assign_value_type->hasMixed()) {
             $root_var_id = ExpressionIdentifier::getRootVarId(
                 $assign_var,
@@ -549,57 +494,16 @@ public static function analyze(
                 }
             }
 
-            if ($statements_analyzer->data_flow_graph) {
-                $data_flow_graph = $statements_analyzer->data_flow_graph;
-
-                if ($context->vars_in_scope[$var_id]->parent_nodes) {
-                    if ($data_flow_graph instanceof TaintFlowGraph
-                        && in_array('TaintedInput', $statements_analyzer->getSuppressedIssues())
-                    ) {
-                        $context->vars_in_scope[$var_id] = $context->vars_in_scope[$var_id]->setParentNodes([]);
-                    } else {
-                        $var_location = new CodeLocation($statements_analyzer->getSource(), $assign_var);
-
-                        $event = new AddRemoveTaintsEvent($assign_var, $context, $statements_analyzer, $codebase);
-
-                        $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
-                        $removed_taints = [
-                            ...$removed_taints,
-                            ...$codebase->config->eventDispatcher->dispatchRemoveTaints($event),
-                        ];
-
-                        self::taintAssignment(
-                            $context->vars_in_scope[$var_id],
-                            $data_flow_graph,
-                            $var_id,
-                            $var_location,
-                            $removed_taints,
-                            $added_taints,
-                        );
-                    }
-
-                    if ($assign_expr) {
-                        $new_parent_node = DataFlowNode::getForAssignment(
-                            'assignment_expr',
-                            new CodeLocation($statements_analyzer->getSource(), $assign_expr),
-                        );
-
-                        $data_flow_graph->addNode($new_parent_node);
-
-                        foreach ($context->vars_in_scope[$var_id]->parent_nodes as $old_parent_node) {
-                            $data_flow_graph->addPath(
-                                $old_parent_node,
-                                $new_parent_node,
-                                '=',
-                            );
-                        }
-
-                        $assign_value_type = $assign_value_type->setParentNodes(
-                            [$new_parent_node->id => $new_parent_node],
-                        );
-                    }
-                }
-            }
+            self::analyzeAssignValueDataFlow(
+                $statements_analyzer,
+                $codebase,
+                $assign_var,
+                $assign_expr,
+                $assign_value_type,
+                $var_id,
+                $context,
+                $removed_taints,
+            );
         }
 
         $context->inside_assignment = $was_in_assignment;
@@ -692,6 +596,86 @@ private static function analyzeAssignment(
         return null;
     }
 
+    /**
+     * @param list<VarDocblockComment> $var_comments
+     * @param list<string> $removed_taints
+     */
+    private static function analyzeDocComment(
+        StatementsAnalyzer $statements_analyzer,
+        Codebase $codebase,
+        Context $context,
+        PhpParser\Node $assign_var,
+        ?string $var_id,
+        ?Union $assign_value_type,
+        ?Doc $doc_comment,
+        array &$var_comments,
+        ?Union &$comment_type,
+        ?DocblockTypeLocation &$comment_type_location,
+        array $not_ignored_docblock_var_ids,
+        array &$removed_taints,
+    ): void {
+        if (!$doc_comment) {
+            return;
+        }
+
+        $file_path = $statements_analyzer->getRootFilePath();
+
+        $file_storage_provider = $codebase->file_storage_provider;
+
+        $file_storage = $file_storage_provider->get($file_path);
+
+        $template_type_map = $statements_analyzer->getTemplateTypeMap();
+
+        try {
+            $var_comments = $codebase->config->disable_var_parsing
+                ? []
+                : CommentAnalyzer::getTypeFromComment(
+                    $doc_comment,
+                    $statements_analyzer->getSource(),
+                    $statements_analyzer->getAliases(),
+                    $template_type_map,
+                    $file_storage->type_aliases,
+                );
+        } catch (IncorrectDocblockException $e) {
+            IssueBuffer::maybeAdd(
+                new MissingDocblockType(
+                    $e->getMessage(),
+                    new CodeLocation($statements_analyzer->getSource(), $assign_var),
+                ),
+            );
+
+            return;
+        } catch (DocblockParseException $e) {
+            IssueBuffer::maybeAdd(
+                new InvalidDocblock(
+                    $e->getMessage(),
+                    new CodeLocation($statements_analyzer->getSource(), $assign_var),
+                ),
+            );
+
+            return;
+        }
+
+        foreach ($var_comments as $var_comment) {
+            if ($var_comment->removed_taints) {
+                $removed_taints = $var_comment->removed_taints;
+            }
+
+            self::assignTypeFromVarDocblock(
+                $statements_analyzer,
+                $assign_var,
+                $var_comment,
+                $context,
+                $var_id,
+                $comment_type,
+                $comment_type_location,
+                $not_ignored_docblock_var_ids,
+                $var_id === $var_comment->var_id
+                    && $assign_value_type && $comment_type && $assign_value_type->by_ref,
+            );
+        }
+    }
+
     public static function assignTypeFromVarDocblock(
         StatementsAnalyzer $statements_analyzer,
         PhpParser\Node $stmt,
@@ -806,8 +790,8 @@ public static function assignTypeFromVarDocblock(
     }
 
     /**
-     * @param  array<string> $removed_taints
-     * @param  array<string> $added_taints
+     * @param list<string> $removed_taints
+     * @param list<string> $added_taints
      */
     private static function taintAssignment(
         Union &$type,
@@ -823,6 +807,15 @@ private static function taintAssignment(
         $data_flow_graph->addNode($new_parent_node);
         $new_parent_nodes = [$new_parent_node->id => $new_parent_node];
 
+        // If taints get added (e.g. due to plugin) this assignment needs to
+        // become a new taint source
+        $taints = array_diff($added_taints, $removed_taints);
+        if ($taints !== [] && $data_flow_graph instanceof TaintFlowGraph) {
+            $taint_source = TaintSource::fromNode($new_parent_node);
+            $taint_source->taints = $taints;
+            $data_flow_graph->addSource($taint_source);
+        }
+
         foreach ($parent_nodes as $parent_node) {
             $data_flow_graph->addPath(
                 $parent_node,
@@ -1847,4 +1840,101 @@ private static function analyzeAssignmentToVariable(
             }
         }
     }
+
+    private static function analyzeVariableUse(
+        StatementsAnalyzer $statements_analyzer,
+        PhpParser\Node\Expr $assign_var,
+        ?string $extended_var_id,
+        Union $assign_value_type,
+        Context $context,
+    ): Union {
+        if ($extended_var_id) {
+            $assignment_node = DataFlowNode::getForAssignment(
+                $extended_var_id,
+                new CodeLocation($statements_analyzer->getSource(), $assign_var),
+            );
+        } else {
+            $assignment_node = new DataFlowNode('unknown-origin', 'unknown origin', null);
+        }
+
+        $parent_nodes = [
+            $assignment_node->id => $assignment_node,
+        ];
+
+        if ($context->inside_try) {
+            // Copy previous assignment's parent nodes inside a try. Since an exception could be thrown at any
+            // point this is a workaround to ensure that use of a variable also uses all previous assignments.
+            if (isset($context->vars_in_scope[$extended_var_id])) {
+                $parent_nodes += $context->vars_in_scope[$extended_var_id]->parent_nodes;
+            }
+        }
+
+        return $assign_value_type->setParentNodes($parent_nodes);
+    }
+
+    /**
+     * @param list<string> $removed_taints
+     */
+    private static function analyzeAssignValueDataFlow(
+        StatementsAnalyzer $statements_analyzer,
+        Codebase $codebase,
+        PhpParser\Node\Expr $assign_var,
+        ?PhpParser\Node\Expr $assign_expr,
+        Union &$assign_value_type,
+        string $var_id,
+        Context $context,
+        array $removed_taints,
+    ): void {
+        if (!$statements_analyzer->data_flow_graph
+            || !$context->vars_in_scope[$var_id]->parent_nodes) {
+            return;
+        }
+
+        $data_flow_graph = $statements_analyzer->data_flow_graph;
+        if ($data_flow_graph instanceof TaintFlowGraph
+            && in_array('TaintedInput', $statements_analyzer->getSuppressedIssues())
+        ) {
+            $context->vars_in_scope[$var_id] = $context->vars_in_scope[$var_id]->setParentNodes([]);
+        } else {
+            $var_location = new CodeLocation($statements_analyzer->getSource(), $assign_var);
+
+            $event = new AddRemoveTaintsEvent($assign_var, $context, $statements_analyzer, $codebase);
+
+            $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
+            $removed_taints = [
+                ...$removed_taints,
+                ...$codebase->config->eventDispatcher->dispatchRemoveTaints($event),
+            ];
+
+            self::taintAssignment(
+                $context->vars_in_scope[$var_id],
+                $data_flow_graph,
+                $var_id,
+                $var_location,
+                $removed_taints,
+                $added_taints,
+            );
+        }
+
+        if ($assign_expr) {
+            $new_parent_node = DataFlowNode::getForAssignment(
+                'assignment_expr',
+                new CodeLocation($statements_analyzer->getSource(), $assign_expr),
+            );
+
+            $data_flow_graph->addNode($new_parent_node);
+
+            foreach ($context->vars_in_scope[$var_id]->parent_nodes as $old_parent_node) {
+                $data_flow_graph->addPath(
+                    $old_parent_node,
+                    $new_parent_node,
+                    '=',
+                );
+            }
+
+            $assign_value_type = $assign_value_type->setParentNodes(
+                [$new_parent_node->id => $new_parent_node],
+            );
+        }
+    }
 }
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOpAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOpAnalyzer.php
index 9a782307f13..abbdd8fff52 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOpAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOpAnalyzer.php
@@ -18,6 +18,7 @@
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\Codebase\VariableUseGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\MethodIdentifier;
 use Psalm\Issue\DocblockTypeContradiction;
 use Psalm\Issue\ImpureMethodCall;
@@ -34,6 +35,7 @@
 use Psalm\Type\Union;
 use UnexpectedValueException;
 
+use function array_diff;
 use function in_array;
 use function strlen;
 
@@ -171,6 +173,13 @@ public static function analyze(
                 $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
                 $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+                $taints = array_diff($added_taints, $removed_taints);
+                if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+                    $taint_source = TaintSource::fromNode($new_parent_node);
+                    $taint_source->taints = $taints;
+                    $statements_analyzer->data_flow_graph->addSource($taint_source);
+                }
+
                 if ($stmt_left_type && $stmt_left_type->parent_nodes) {
                     foreach ($stmt_left_type->parent_nodes as $parent_node) {
                         $statements_analyzer->data_flow_graph->addPath(
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php
index 90134492699..c051cc6f26e 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php
@@ -22,6 +22,7 @@
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\Codebase\VariableUseGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\MethodIdentifier;
 use Psalm\Internal\Type\Comparator\CallableTypeComparator;
 use Psalm\Internal\Type\Comparator\TypeComparisonResult;
@@ -64,6 +65,7 @@
 use Psalm\Type\Union;
 use UnexpectedValueException;
 
+use function array_diff;
 use function array_filter;
 use function count;
 use function explode;
@@ -1892,5 +1894,12 @@ private static function processTaintedness(
                 $removed_taints,
             );
         }
+
+        $taints = array_diff($added_taints, $removed_taints);
+        if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+            $taint_source = TaintSource::fromNode($argument_value_node);
+            $taint_source->taints = $taints;
+            $statements_analyzer->data_flow_graph->addSource($taint_source);
+        }
     }
 }
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php
index 76501316200..18db16871fc 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php
@@ -19,6 +19,7 @@
 use Psalm\Internal\Codebase\InternalCallMapHandler;
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\DataFlow\TaintSink;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\MethodIdentifier;
 use Psalm\Internal\Type\Comparator\CallableTypeComparator;
 use Psalm\Internal\Type\TemplateResult;
@@ -62,6 +63,7 @@
 use Psalm\Type\Union;
 use UnexpectedValueException;
 
+use function array_diff;
 use function array_map;
 use function array_merge;
 use function array_shift;
@@ -853,6 +855,13 @@ private static function getAnalyzeNamedExpression(
                 $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
                 $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+                $taints = array_diff($added_taints, $removed_taints);
+                if ($taints !== []) {
+                    $taint_source = TaintSource::fromNode($custom_call_sink);
+                    $taint_source->taints = $taints;
+                    $statements_analyzer->data_flow_graph->addSource($taint_source);
+                }
+
                 foreach ($stmt_name_type->parent_nodes as $parent_node) {
                     $statements_analyzer->data_flow_graph->addPath(
                         $parent_node,
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php
index 65763215a18..9aec38e44a9 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php
@@ -43,6 +43,7 @@
 use Psalm\Type\Union;
 use UnexpectedValueException;
 
+use function array_diff;
 use function array_merge;
 use function array_values;
 use function count;
@@ -266,6 +267,7 @@ public static function fetch(
             $statements_analyzer,
             $stmt,
             $function_id,
+            $function_name->toCodeString(),
             $function_storage,
             $stmt_type,
             $template_result,
@@ -535,6 +537,7 @@ private static function taintReturnType(
         StatementsAnalyzer $statements_analyzer,
         PhpParser\Node\Expr\FuncCall $stmt,
         string $function_id,
+        string $cased_function_id,
         FunctionLikeStorage $function_storage,
         Union &$stmt_type,
         TemplateResult $template_result,
@@ -560,13 +563,12 @@ private static function taintReturnType(
 
         $function_call_node = DataFlowNode::getForMethodReturn(
             $function_id,
-            $function_id,
+            $cased_function_id,
             $statements_analyzer->data_flow_graph instanceof TaintFlowGraph
                 ? ($function_storage->signature_return_type_location ?: $function_storage->location)
                 : ($function_storage->return_type_location ?: $function_storage->location),
             $function_storage->specialize_call ? $node_location : null,
         );
-
         $statements_analyzer->data_flow_graph->addNode($function_call_node);
 
         $codebase = $statements_analyzer->getCodebase();
@@ -617,9 +619,11 @@ private static function taintReturnType(
             $stmt_type = $stmt_type->addParentNodes([$function_call_node->id => $function_call_node]);
         }
 
-        if ($function_storage->return_source_params
-            && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph
-        ) {
+        if (!$statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+            return $function_call_node;
+        }
+
+        if ($function_storage->return_source_params) {
             $removed_taints = $function_storage->removed_taints;
 
             if ($function_id === 'preg_replace' && count($stmt->getArgs()) > 2) {
@@ -673,17 +677,7 @@ private static function taintReturnType(
             );
         }
 
-        if ($function_storage->taint_source_types && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
-            $method_node = TaintSource::getForMethodReturn(
-                $function_id,
-                $function_id,
-                $node_location,
-            );
-
-            $method_node->taints = $function_storage->taint_source_types;
-
-            $statements_analyzer->data_flow_graph->addSource($method_node);
-        }
+        self::taintUsingStorage($function_storage, $statements_analyzer->data_flow_graph, $function_call_node);
 
         return $function_call_node;
     }
@@ -746,6 +740,30 @@ public static function taintUsingFlows(
         }
     }
 
+    public static function taintUsingStorage(
+        FunctionLikeStorage $function_storage,
+        TaintFlowGraph $graph,
+        DataFlowNode $function_call_node,
+    ): void {
+        // Docblock-defined taints should override inherited
+        $added_taints = [];
+        if ($function_storage->taint_source_types !== []) {
+            $added_taints = $function_storage->taint_source_types;
+        } elseif ($function_storage->added_taints !== []) {
+            $added_taints = $function_storage->added_taints;
+        }
+
+        $taints = array_diff(
+            $added_taints,
+            $function_storage->removed_taints,
+        );
+        if ($taints !== []) {
+            $taint_source = TaintSource::fromNode($function_call_node);
+            $taint_source->taints = $taints;
+            $graph->addSource($taint_source);
+        }
+    }
+
     /**
      * @psalm-pure
      */
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php
index 3d3b39e5b37..b9d2fbcf9ce 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php
@@ -17,7 +17,6 @@
 use Psalm\Internal\Codebase\InternalCallMapHandler;
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
-use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\MethodIdentifier;
 use Psalm\Internal\Type\TemplateBound;
 use Psalm\Internal\Type\TemplateInferredTypeReplacer;
@@ -533,18 +532,6 @@ public static function taintMethodCallResult(
             return;
         }
 
-        if ($method_storage->taint_source_types) {
-            $method_node = TaintSource::getForMethodReturn(
-                (string) $method_id,
-                $cased_method_id,
-                $method_storage->signature_return_type_location ?: $method_storage->location,
-            );
-
-            $method_node->taints = $method_storage->taint_source_types;
-
-            $statements_analyzer->data_flow_graph->addSource($method_node);
-        }
-
         FunctionCallReturnTypeFetcher::taintUsingFlows(
             $statements_analyzer,
             $method_storage,
@@ -555,6 +542,12 @@ public static function taintMethodCallResult(
             $method_call_node,
             $method_storage->removed_taints,
         );
+
+        FunctionCallReturnTypeFetcher::taintUsingStorage(
+            $method_storage,
+            $statements_analyzer->data_flow_graph,
+            $method_call_node,
+        );
     }
 
     public static function replaceTemplateTypes(
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php
index f4b7e5c09f7..92edae0c543 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php
@@ -21,6 +21,7 @@
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
 use Psalm\Internal\DataFlow\TaintSink;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\MethodIdentifier;
 use Psalm\Internal\Type\TemplateResult;
 use Psalm\Internal\Type\TemplateStandinTypeReplacer;
@@ -60,6 +61,7 @@
 use Psalm\Type\TaintKind;
 use Psalm\Type\Union;
 
+use function array_diff;
 use function array_map;
 use function array_values;
 use function count;
@@ -740,6 +742,13 @@ private static function analyzeConstructorExpression(
                 $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
                 $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+                $taints = array_diff($added_taints, $removed_taints);
+                if ($added_taints !== []) {
+                    $taint_source = TaintSource::fromNode($custom_call_sink);
+                    $taint_source->taints = $taints;
+                    $statements_analyzer->data_flow_graph->addSource($taint_source);
+                }
+
                 foreach ($stmt_class_type->parent_nodes as $parent_node) {
                     $statements_analyzer->data_flow_graph->addPath(
                         $parent_node,
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/EncapsulatedStringAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/EncapsulatedStringAnalyzer.php
index 88ef13bc054..ba5a41f0f44 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/EncapsulatedStringAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/EncapsulatedStringAnalyzer.php
@@ -11,7 +11,9 @@
 use Psalm\Context;
 use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer;
 use Psalm\Internal\Analyzer\StatementsAnalyzer;
+use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent;
 use Psalm\Type;
 use Psalm\Type\Atomic\TLiteralFloat;
@@ -24,6 +26,7 @@
 use Psalm\Type\Atomic\TString;
 use Psalm\Type\Union;
 
+use function array_diff;
 use function in_array;
 
 /**
@@ -106,6 +109,13 @@ public static function analyze(
                     $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
                     $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+                    $taints = array_diff($added_taints, $removed_taints);
+                    if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+                        $taint_source = TaintSource::fromNode($new_parent_node);
+                        $taint_source->taints = $taints;
+                        $statements_analyzer->data_flow_graph->addSource($taint_source);
+                    }
+
                     if ($casted_part_type->parent_nodes) {
                         foreach ($casted_part_type->parent_nodes as $parent_node) {
                             $statements_analyzer->data_flow_graph->addPath(
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/EvalAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/EvalAnalyzer.php
index 093dd94e10d..421545eec60 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/EvalAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/EvalAnalyzer.php
@@ -11,11 +11,13 @@
 use Psalm\Internal\Analyzer\StatementsAnalyzer;
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\DataFlow\TaintSink;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Issue\ForbiddenCode;
 use Psalm\IssueBuffer;
 use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent;
 use Psalm\Type\TaintKind;
 
+use function array_diff;
 use function in_array;
 
 /**
@@ -62,6 +64,13 @@ public static function analyze(
                 $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
                 $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+                $taints = array_diff($added_taints, $removed_taints);
+                if ($taints !== []) {
+                    $taint_source = TaintSource::fromNode($eval_param_sink);
+                    $taint_source->taints = $taints;
+                    $statements_analyzer->data_flow_graph->addSource($taint_source);
+                }
+
                 foreach ($expr_type->parent_nodes as $parent_node) {
                     $statements_analyzer->data_flow_graph->addPath(
                         $parent_node,
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php
index 5ca784170fa..4186103eecd 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php
@@ -19,6 +19,7 @@
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\Codebase\VariableUseGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\Type\Comparator\AtomicTypeComparator;
 use Psalm\Internal\Type\Comparator\TypeComparisonResult;
 use Psalm\Internal\Type\Comparator\UnionTypeComparator;
@@ -88,6 +89,7 @@
 use Psalm\Type\Union;
 use UnexpectedValueException;
 
+use function array_diff;
 use function array_keys;
 use function array_map;
 use function array_pop;
@@ -394,6 +396,13 @@ public static function taintArrayFetch(
                 return;
             }
 
+            $var_location = new CodeLocation($statements_analyzer->getSource(), $var);
+
+            $new_parent_node = DataFlowNode::getForAssignment(
+                $keyed_array_var_id ?: 'arrayvalue-fetch',
+                $var_location,
+            );
+
             $added_taints = [];
             $removed_taints = [];
 
@@ -403,14 +412,14 @@ public static function taintArrayFetch(
 
                 $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
                 $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
-            }
 
-            $var_location = new CodeLocation($statements_analyzer->getSource(), $var);
-
-            $new_parent_node = DataFlowNode::getForAssignment(
-                $keyed_array_var_id ?: 'arrayvalue-fetch',
-                $var_location,
-            );
+                $taints = array_diff($added_taints, $removed_taints);
+                if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+                    $taint_source = TaintSource::fromNode($new_parent_node);
+                    $taint_source->taints = $taints;
+                    $statements_analyzer->data_flow_graph->addSource($taint_source);
+                }
+            }
 
             $array_key_node = null;
 
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php
index 3448ca749aa..ae51eb7fd9e 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php
@@ -24,6 +24,7 @@
 use Psalm\Internal\Analyzer\StatementsAnalyzer;
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\DataFlow\DataFlowNode;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\FileManipulation\FileManipulationBuffer;
 use Psalm\Internal\MethodIdentifier;
 use Psalm\Internal\Type\TemplateInferredTypeReplacer;
@@ -62,6 +63,7 @@
 use Psalm\Type\Atomic\TTemplateParam;
 use Psalm\Type\Union;
 
+use function array_diff;
 use function array_filter;
 use function array_keys;
 use function array_map;
@@ -898,6 +900,13 @@ public static function processTaints(
                 }
 
                 $type = $type->setParentNodes([$property_node->id => $property_node], true);
+
+                $taints = array_diff($added_taints, $removed_taints);
+                if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+                    $taint_source = TaintSource::fromNode($var_node);
+                    $taint_source->taints = $taints;
+                    $statements_analyzer->data_flow_graph->addSource($taint_source);
+                }
             }
         } else {
             self::processUnspecialTaints(
@@ -974,6 +983,13 @@ public static function processUnspecialTaints(
         }
 
         $type = $type->setParentNodes([$localized_property_node->id => $localized_property_node], true);
+
+        $taints = array_diff($added_taints ?? [], $removed_taints ?? []);
+        if ($taints !== [] && $statements_analyzer->data_flow_graph instanceof TaintFlowGraph) {
+            $taint_source = TaintSource::fromNode($localized_property_node);
+            $taint_source->taints = $taints;
+            $statements_analyzer->data_flow_graph->addSource($taint_source);
+        }
     }
 
     private static function handleEnumName(
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/VariableFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/VariableFetchAnalyzer.php
index b7766aa2617..53a6b6542e9 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/VariableFetchAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/VariableFetchAnalyzer.php
@@ -22,6 +22,7 @@
 use Psalm\Issue\UndefinedGlobalVariable;
 use Psalm\Issue\UndefinedVariable;
 use Psalm\IssueBuffer;
+use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent;
 use Psalm\Type;
 use Psalm\Type\Atomic\TArray;
 use Psalm\Type\Atomic\TBool;
@@ -36,6 +37,9 @@
 use Psalm\Type\TaintKindGroup;
 use Psalm\Type\Union;
 
+use function array_diff;
+use function array_merge;
+use function array_unique;
 use function in_array;
 use function is_string;
 use function time;
@@ -166,7 +170,7 @@ public static function analyze(
             if (isset($context->vars_in_scope[$var_name])) {
                 $type = $context->vars_in_scope[$var_name];
 
-                self::taintVariable($statements_analyzer, $var_name, $type, $stmt);
+                self::taintVariable($statements_analyzer, $context, $var_name, $type, $stmt);
 
                 $context->vars_in_scope[$var_name] = $type;
                 $statements_analyzer->node_data->setType($stmt, $type);
@@ -176,7 +180,7 @@ public static function analyze(
 
             $type = self::getGlobalType($var_name, $codebase->analysis_php_version_id);
 
-            self::taintVariable($statements_analyzer, $var_name, $type, $stmt);
+            self::taintVariable($statements_analyzer, $context, $var_name, $type, $stmt);
 
             $statements_analyzer->node_data->setType($stmt, $type);
             $context->vars_in_scope[$var_name] = $type;
@@ -252,6 +256,8 @@ public static function analyze(
                             $context->branch_point,
                         );
                     }
+
+                    self::taintVariable($statements_analyzer, $context, $var_name, $stmt_type, $stmt);
                     $statements_analyzer->node_data->setType($stmt, $stmt_type);
 
                     if ($assigned_to_reference) {
@@ -266,29 +272,27 @@ public static function analyze(
                     || $statements_analyzer->getSource() instanceof FunctionLikeAnalyzer
                 ) {
                     if ($context->is_global || $from_global) {
-                        IssueBuffer::maybeAdd(
-                            new UndefinedGlobalVariable(
-                                'Cannot find referenced variable ' . $var_name . ' in global scope',
-                                new CodeLocation($statements_analyzer->getSource(), $stmt),
-                                $var_name,
-                            ),
-                            $statements_analyzer->getSuppressedIssues(),
+                        $exception = new UndefinedGlobalVariable(
+                            'Cannot find referenced variable ' . $var_name . ' in global scope',
+                            new CodeLocation($statements_analyzer->getSource(), $stmt),
+                            $var_name,
+                        );
+                    } else {
+                        $exception = new UndefinedVariable(
+                            'Cannot find referenced variable ' . $var_name,
+                            new CodeLocation($statements_analyzer->getSource(), $stmt),
                         );
-
-                        $statements_analyzer->node_data->setType($stmt, Type::getMixed());
-
-                        return true;
                     }
 
                     IssueBuffer::maybeAdd(
-                        new UndefinedVariable(
-                            'Cannot find referenced variable ' . $var_name,
-                            new CodeLocation($statements_analyzer->getSource(), $stmt),
-                        ),
+                        $exception,
                         $statements_analyzer->getSuppressedIssues(),
                     );
 
-                    $statements_analyzer->node_data->setType($stmt, Type::getMixed());
+                    $type = Type::getMixed();
+                    self::taintVariable($statements_analyzer, $context, $var_name, $type, $stmt);
+
+                    $statements_analyzer->node_data->setType($stmt, $type);
 
                     return true;
                 }
@@ -370,6 +374,8 @@ public static function analyze(
         } else {
             $stmt_type = $context->vars_in_scope[$var_name];
 
+            self::taintVariable($statements_analyzer, $context, $var_name, $stmt_type, $stmt);
+
             self::addDataFlowToVariable($statements_analyzer, $stmt, $var_name, $stmt_type, $context);
 
             $context->vars_in_scope[$var_name] = $stmt_type;
@@ -505,35 +511,55 @@ private static function addDataFlowToVariable(
 
     private static function taintVariable(
         StatementsAnalyzer $statements_analyzer,
+        Context $context,
         string $var_name,
         Union &$type,
         PhpParser\Node\Expr\Variable $stmt,
     ): void {
-        if ($statements_analyzer->data_flow_graph instanceof TaintFlowGraph
-            && !in_array('TaintedInput', $statements_analyzer->getSuppressedIssues())
+        if (!$statements_analyzer->data_flow_graph instanceof TaintFlowGraph
+            || in_array('TaintedInput', $statements_analyzer->getSuppressedIssues())
         ) {
-            if ($var_name === '$_GET'
-                || $var_name === '$_POST'
-                || $var_name === '$_COOKIE'
-                || $var_name === '$_REQUEST'
-            ) {
-                $taint_location = new CodeLocation($statements_analyzer->getSource(), $stmt);
+            return;
+        }
 
-                $server_taint_source = new TaintSource(
-                    $var_name . ':' . $taint_location->file_name . ':' . $taint_location->raw_file_start,
-                    $var_name,
-                    null,
-                    null,
-                    TaintKindGroup::ALL_INPUT,
-                );
+        // Add superglobal server taint sources
+        if ($var_name === '$_GET'
+            || $var_name === '$_POST'
+            || $var_name === '$_COOKIE'
+            || $var_name === '$_REQUEST'
+        ) {
+            $taints = TaintKindGroup::ALL_INPUT;
+        } else {
+            $taints = [];
+        }
 
-                $statements_analyzer->data_flow_graph->addSource($server_taint_source);
+        // Trigger event to possibly get more/less taints
+        $codebase = $statements_analyzer->getCodebase();
+        $event = new AddRemoveTaintsEvent($stmt, $context, $statements_analyzer, $codebase);
 
-                $type = $type->setParentNodes([
-                    $server_taint_source->id => $server_taint_source,
-                ]);
-            }
+        $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
+        $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
+        $taints = array_unique(array_merge($taints, $added_taints));
+        $taints = array_diff($taints, $removed_taints);
+
+        if ($taints === []) {
+            return;
         }
+
+        $taint_location = new CodeLocation($statements_analyzer->getSource(), $stmt);
+
+        $taint_source = new TaintSource(
+            $var_name . ':' . $taint_location->file_name . ':' . $taint_location->raw_file_start,
+            $var_name,
+            null,
+            null,
+            $taints,
+        );
+        $statements_analyzer->data_flow_graph->addSource($taint_source);
+
+        $type = $type->setParentNodes([
+            $taint_source->id => $taint_source,
+        ]);
     }
 
     /**
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/IncludeAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/IncludeAnalyzer.php
index ce884452f53..32d46362a6b 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/IncludeAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/IncludeAnalyzer.php
@@ -16,6 +16,7 @@
 use Psalm\Internal\Analyzer\StatementsAnalyzer;
 use Psalm\Internal\Codebase\TaintFlowGraph;
 use Psalm\Internal\DataFlow\TaintSink;
+use Psalm\Internal\DataFlow\TaintSource;
 use Psalm\Internal\Provider\NodeDataProvider;
 use Psalm\Issue\MissingFile;
 use Psalm\Issue\UnresolvableInclude;
@@ -24,6 +25,7 @@
 use Psalm\Type\TaintKind;
 use Symfony\Component\Filesystem\Path;
 
+use function array_diff;
 use function constant;
 use function defined;
 use function dirname;
@@ -134,6 +136,13 @@ public static function analyze(
             $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
             $removed_taints = $codebase->config->eventDispatcher->dispatchRemoveTaints($event);
 
+            $taints = array_diff($added_taints, $removed_taints);
+            if ($taints !== []) {
+                $taint_source = TaintSource::fromNode($include_param_sink);
+                $taint_source->taints = $taints;
+                $statements_analyzer->data_flow_graph->addSource($taint_source);
+            }
+
             foreach ($stmt_expr_type->parent_nodes as $parent_node) {
                 $statements_analyzer->data_flow_graph->addPath(
                     $parent_node,
diff --git a/src/Psalm/Internal/Analyzer/Statements/ReturnAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/ReturnAnalyzer.php
index ec109d6b84c..7d70252fc3b 100644
--- a/src/Psalm/Internal/Analyzer/Statements/ReturnAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/ReturnAnalyzer.php
@@ -37,6 +37,7 @@
 use Psalm\Issue\NonVariableReferenceReturn;
 use Psalm\Issue\NullableReturnStatement;
 use Psalm\IssueBuffer;
+use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent;
 use Psalm\Storage\FunctionLikeStorage;
 use Psalm\Storage\MethodStorage;
 use Psalm\Type;
@@ -46,6 +47,8 @@
 use Psalm\Type\Atomic\TClosure;
 use Psalm\Type\Union;
 
+use function array_merge;
+use function array_unique;
 use function count;
 use function explode;
 use function implode;
@@ -265,6 +268,7 @@ public static function analyze(
                         $cased_method_id,
                         $inferred_type,
                         $storage,
+                        $context,
                     );
                 }
 
@@ -579,6 +583,7 @@ private static function handleTaints(
         string $cased_method_id,
         Union $inferred_type,
         FunctionLikeStorage $storage,
+        Context $context,
     ): void {
         if (!$statements_analyzer->data_flow_graph instanceof TaintFlowGraph
             || !$stmt->expr
@@ -595,6 +600,24 @@ private static function handleTaints(
 
         $statements_analyzer->data_flow_graph->addNode($method_node);
 
+        $codebase = $statements_analyzer->getCodebase();
+
+        $event = new AddRemoveTaintsEvent($stmt->expr, $context, $statements_analyzer, $codebase);
+
+        $added_taints = $codebase->config->eventDispatcher->dispatchAddTaints($event);
+        $storage->added_taints = array_unique(
+            array_merge(
+                $storage->added_taints,
+                $added_taints,
+            ),
+        );
+        $storage->removed_taints = array_unique(
+            array_merge(
+                $storage->removed_taints,
+                $codebase->config->eventDispatcher->dispatchRemoveTaints($event),
+            ),
+        );
+
         if ($inferred_type->parent_nodes) {
             foreach ($inferred_type->parent_nodes as $parent_node) {
                 $statements_analyzer->data_flow_graph->addPath(
diff --git a/src/Psalm/Internal/DataFlow/TaintSource.php b/src/Psalm/Internal/DataFlow/TaintSource.php
index 5b5f076dd1e..e971bf19237 100644
--- a/src/Psalm/Internal/DataFlow/TaintSource.php
+++ b/src/Psalm/Internal/DataFlow/TaintSource.php
@@ -9,4 +9,14 @@
  */
 final class TaintSource extends DataFlowNode
 {
+    public static function fromNode(DataFlowNode $node): self
+    {
+        return new self(
+            $node->id,
+            $node->label,
+            $node->code_location,
+            $node->specialization_key,
+            $node->taints,
+        );
+    }
 }
diff --git a/tests/Config/Plugin/EventHandler/AddTaints/AddTaintsInterfaceTest.php b/tests/Config/Plugin/EventHandler/AddTaints/AddTaintsInterfaceTest.php
new file mode 100644
index 00000000000..b7fca079456
--- /dev/null
+++ b/tests/Config/Plugin/EventHandler/AddTaints/AddTaintsInterfaceTest.php
@@ -0,0 +1,407 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Psalm\Tests\Config\Plugin\EventHandler\AddTaints;
+
+use Psalm\Config;
+use Psalm\Context;
+use Psalm\Exception\CodeException;
+use Psalm\Internal\Analyzer\ProjectAnalyzer;
+use Psalm\Internal\IncludeCollector;
+use Psalm\Internal\Provider\FakeFileProvider;
+use Psalm\Internal\Provider\Providers;
+use Psalm\Internal\RuntimeCaches;
+use Psalm\Report\ReportOptions;
+use Psalm\Tests\Internal\Provider\FakeParserCacheProvider;
+use Psalm\Tests\TestCase;
+use Psalm\Tests\TestConfig;
+
+use function define;
+use function defined;
+use function dirname;
+use function getcwd;
+
+use const DIRECTORY_SEPARATOR;
+
+class AddTaintsInterfaceTest extends TestCase
+{
+    protected static TestConfig $config;
+
+    public static function setUpBeforeClass(): void
+    {
+        self::$config = new TestConfig();
+
+        if (!defined('PSALM_VERSION')) {
+            define('PSALM_VERSION', '4.0.0');
+        }
+
+        if (!defined('PHP_PARSER_VERSION')) {
+            define('PHP_PARSER_VERSION', '4.0.0');
+        }
+    }
+
+    private function getProjectAnalyzerWithConfig(Config $config): ProjectAnalyzer
+    {
+        $config->setIncludeCollector(new IncludeCollector());
+        return new ProjectAnalyzer(
+            $config,
+            new Providers(
+                $this->file_provider,
+                new FakeParserCacheProvider(),
+            ),
+            new ReportOptions(),
+        );
+    }
+
+    private function setupProjectAnalyzerWithTaintBadDataPlugin(): void
+    {
+        $this->project_analyzer = $this->getProjectAnalyzerWithConfig(
+            TestConfig::loadFromXML(
+                dirname(__DIR__, 5) . DIRECTORY_SEPARATOR,
+                '<?xml version="1.0"?>
+                <psalm
+                    errorLevel="6"
+                    runTaintAnalysis="true"
+                >
+                    <projectFiles>
+                        <directory name="src" />
+                    </projectFiles>
+                    <plugins>
+                        <plugin filename="tests/Config/Plugin/EventHandler/AddTaints/TaintBadDataPlugin.php" />
+                    </plugins>
+                </psalm>',
+            ),
+        );
+        $this->project_analyzer->getCodebase()->config->initializePlugins($this->project_analyzer);
+    }
+
+    private function setupProjectAnalyzerWithActiveRecordPlugin(): void
+    {
+        $this->project_analyzer = $this->getProjectAnalyzerWithConfig(
+            TestConfig::loadFromXML(
+                dirname(__DIR__, 5) . DIRECTORY_SEPARATOR,
+                '<?xml version="1.0"?>
+                <psalm
+                    errorLevel="6"
+                    runTaintAnalysis="true"
+                >
+                    <projectFiles>
+                        <directory name="src" />
+                    </projectFiles>
+                    <plugins>
+                        <plugin filename="examples/plugins/TaintActiveRecords.php" />
+                    </plugins>
+                </psalm>',
+            ),
+        );
+        $this->project_analyzer->getCodebase()->config->initializePlugins($this->project_analyzer);
+    }
+
+    private function expectTaintedHtml(): void
+    {
+        $this->project_analyzer->trackTaintedInputs();
+
+        $this->expectException(CodeException::class);
+        $this->expectExceptionMessage('TaintedHtml');
+    }
+
+    public function setUp(): void
+    {
+        RuntimeCaches::clearAll();
+        $this->file_provider = new FakeFileProvider();
+    }
+
+    public function testTaintBadDataVariables(): void
+    {
+        $this->setupProjectAnalyzerWithTaintBadDataPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            echo $bad_data;
+            ',
+        );
+
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testTaintsArePassedByTaintedAssignments(): void
+    {
+        $this->setupProjectAnalyzerWithTaintBadDataPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            $foo = $bad_data;
+            echo $foo;
+            ',
+        );
+
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testTaintsAreOverriddenByRawAssignments(): void
+    {
+        $this->setupProjectAnalyzerWithTaintBadDataPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            $foo = $bad_data;
+            $foo = "I am not bad!";
+            echo $foo;
+            ',
+        );
+
+        $this->project_analyzer->trackTaintedInputs();
+        // No exceptions should be thrown
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testTaintsArePassedByTaintedFuncReturns(): void
+    {
+        $this->setupProjectAnalyzerWithTaintBadDataPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            function genBadData() {
+                return $bad_html;
+            }
+
+            echo genBadData();
+            ',
+        );
+
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testTaintsArePassedByTaintedFuncMultipleReturns(): void
+    {
+        $this->setupProjectAnalyzerWithTaintBadDataPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        // Test that taints are merged and not replaced by later return stmts
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            function genBadData(bool $html) {
+                if ($html) {
+                    return $bad_html;
+                }
+                return $bad_sql;
+            }
+
+            echo genBadData(false);
+            ',
+        );
+
+        // Find TaintedHtml here, not TaintedSql, as this is not a sink for echo
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testTaintsArePassedByTaintedMethodReturns(): void
+    {
+        $this->setupProjectAnalyzerWithTaintBadDataPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            class Foo {
+                public function genBadData() {
+                    return $bad_html;
+                }
+            }
+
+            $foo = new Foo();
+            echo $foo->genBadData();
+            ',
+        );
+
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testTaintsArePassedByTaintedStaticMethodReturns(): void
+    {
+        $this->setupProjectAnalyzerWithTaintBadDataPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            class Foo {
+                public static function genBadData() {
+                    return $bad_html;
+                }
+            }
+
+            echo Foo::genBadData();
+            ',
+        );
+
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testTaintsArePassedByProxyCalls(): void
+    {
+        $this->setupProjectAnalyzerWithTaintBadDataPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            class Foo {
+                public static function genBadData() {
+                    return $bad_html;
+                }
+            }
+
+            class Bar {
+                public static function proxy() {
+                    return Foo::genBadData();
+                }
+            }
+
+            echo Bar::proxy();
+            ',
+        );
+
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testAddTaintsActiveRecord(): void
+    {
+        $this->setupProjectAnalyzerWithActiveRecordPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            namespace app\models;
+
+            class User {
+                public string $name = "<h1>Micky Mouse</h1>";
+            }
+
+            $user = new User();
+            echo $user->name;
+            ',
+        );
+
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testAddTaintsActiveRecordList(): void
+    {
+        $this->setupProjectAnalyzerWithActiveRecordPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            namespace app\models;
+
+            class User {
+                public $name;
+
+                /**
+                 * @psalm-return list<User>
+                 */
+                public static function findAll(): array {
+                    $mockUser = new self();
+                    $mockUser->name = "<h1>Micky Mouse</h1>";
+
+                    return [$mockUser];
+                }
+            }
+
+            foreach (User::findAll() as $user) {
+                echo $user->name;
+            }
+            ',
+        );
+
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testAddTaintsActiveRecordListItem(): void
+    {
+        $this->setupProjectAnalyzerWithActiveRecordPlugin();
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            namespace app\models;
+
+            class User {
+                public $name;
+
+                /**
+                 * @psalm-return list<User>
+                 */
+                public static function findAll(): array {
+                    $mockUser = new self();
+                    $mockUser->name = "<h1>Micky Mouse</h1>";
+
+                    return [$mockUser];
+                }
+            }
+
+            $users = User::findAll();
+            echo $users[0]->name;
+            ',
+        );
+
+        $this->expectTaintedHtml();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+}
diff --git a/tests/Config/Plugin/EventHandler/AddTaints/TaintBadDataPlugin.php b/tests/Config/Plugin/EventHandler/AddTaints/TaintBadDataPlugin.php
new file mode 100644
index 00000000000..f846159e5cd
--- /dev/null
+++ b/tests/Config/Plugin/EventHandler/AddTaints/TaintBadDataPlugin.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Psalm\Example\Plugin;
+
+use PhpParser\Node\Expr\Variable;
+use Psalm\Plugin\EventHandler\AddTaintsInterface;
+use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent;
+use Psalm\Type\TaintKind;
+use Psalm\Type\TaintKindGroup;
+
+/**
+ * Add input taints to all variables named 'bad_data'
+ *
+ * @psalm-suppress UnusedClass
+ */
+class TaintBadDataPlugin implements AddTaintsInterface
+{
+    /**
+     * Called to see what taints should be added
+     *
+     * @return list<string>
+     */
+    public static function addTaints(AddRemoveTaintsEvent $event): array
+    {
+        $expr = $event->getExpr();
+
+        if (!$expr instanceof Variable) {
+            return [];
+        }
+
+        switch ($expr->name) {
+            case 'bad_data':
+                return TaintKindGroup::ALL_INPUT;
+            case 'bad_sql':
+                return [TaintKind::INPUT_SQL];
+            case 'bad_html':
+                return [TaintKind::INPUT_HTML];
+            case 'bad_eval':
+                return [TaintKind::INPUT_EVAL];
+            case 'bad_file':
+                return [TaintKind::INPUT_FILE];
+        }
+
+        return [];
+    }
+}
diff --git a/tests/Config/Plugin/EventHandler/RemoveTaints/RemoveAllTaintsPlugin.php b/tests/Config/Plugin/EventHandler/RemoveTaints/RemoveAllTaintsPlugin.php
new file mode 100644
index 00000000000..b203c34321b
--- /dev/null
+++ b/tests/Config/Plugin/EventHandler/RemoveTaints/RemoveAllTaintsPlugin.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Psalm\Tests\Config\Plugin\EventHandler\RemoveTaints;
+
+use Psalm\Plugin\EventHandler\Event\AddRemoveTaintsEvent;
+use Psalm\Plugin\EventHandler\RemoveTaintsInterface;
+use Psalm\Type\TaintKindGroup;
+
+/**
+ * @psalm-suppress UnusedClass
+ */
+class RemoveAllTaintsPlugin implements RemoveTaintsInterface
+{
+    /**
+     * Called to see what taints should be removed
+     *
+     * @return list<string>
+     */
+    public static function removeTaints(AddRemoveTaintsEvent $event): array
+    {
+        return TaintKindGroup::ALL_INPUT;
+    }
+}
diff --git a/tests/Config/Plugin/EventHandler/RemoveTaints/RemoveTaintsInterfaceTest.php b/tests/Config/Plugin/EventHandler/RemoveTaints/RemoveTaintsInterfaceTest.php
new file mode 100644
index 00000000000..a72602d5eb2
--- /dev/null
+++ b/tests/Config/Plugin/EventHandler/RemoveTaints/RemoveTaintsInterfaceTest.php
@@ -0,0 +1,175 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Psalm\Tests\Config\Plugin\EventHandler\RemoveTaints;
+
+use Psalm\Config;
+use Psalm\Context;
+use Psalm\Exception\CodeException;
+use Psalm\Internal\Analyzer\ProjectAnalyzer;
+use Psalm\Internal\IncludeCollector;
+use Psalm\Internal\Provider\FakeFileProvider;
+use Psalm\Internal\Provider\Providers;
+use Psalm\Internal\RuntimeCaches;
+use Psalm\Report\ReportOptions;
+use Psalm\Tests\Internal\Provider\FakeParserCacheProvider;
+use Psalm\Tests\TestCase;
+use Psalm\Tests\TestConfig;
+
+use function define;
+use function defined;
+use function dirname;
+use function getcwd;
+
+use const DIRECTORY_SEPARATOR;
+
+class RemoveTaintsInterfaceTest extends TestCase
+{
+    protected static TestConfig $config;
+
+    public static function setUpBeforeClass(): void
+    {
+        self::$config = new TestConfig();
+
+        if (!defined('PSALM_VERSION')) {
+            define('PSALM_VERSION', '4.0.0');
+        }
+
+        if (!defined('PHP_PARSER_VERSION')) {
+            define('PHP_PARSER_VERSION', '4.0.0');
+        }
+    }
+
+    private function getProjectAnalyzerWithConfig(Config $config): ProjectAnalyzer
+    {
+        $config->setIncludeCollector(new IncludeCollector());
+        return new ProjectAnalyzer(
+            $config,
+            new Providers(
+                $this->file_provider,
+                new FakeParserCacheProvider(),
+            ),
+            new ReportOptions(),
+        );
+    }
+
+    public function setUp(): void
+    {
+        RuntimeCaches::clearAll();
+        $this->file_provider = new FakeFileProvider();
+    }
+
+
+    public function testRemoveAllTaints(): void
+    {
+        $this->project_analyzer = $this->getProjectAnalyzerWithConfig(
+            TestConfig::loadFromXML(
+                dirname(__DIR__, 5) . DIRECTORY_SEPARATOR,
+                '<?xml version="1.0"?>
+                <psalm
+                    errorLevel="6"
+                    runTaintAnalysis="true"
+                >
+                    <projectFiles>
+                        <directory name="src" />
+                    </projectFiles>
+                    <plugins>
+                        <plugin filename="tests/Config/Plugin/EventHandler/RemoveTaints/RemoveAllTaintsPlugin.php" />
+                    </plugins>
+                </psalm>',
+            ),
+        );
+
+        $this->project_analyzer->getCodebase()->config->initializePlugins($this->project_analyzer);
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            /**
+             * @psalm-taint-sink html $string
+             */
+            function output($string) {}
+
+            echo $_POST["username"];',
+        );
+
+        $this->project_analyzer->trackTaintedInputs();
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
+    public function testRemoveTaintsSafeArrayKeyChecker(): void
+    {
+        $this->project_analyzer = $this->getProjectAnalyzerWithConfig(
+            TestConfig::loadFromXML(
+                dirname(__DIR__, 5) . DIRECTORY_SEPARATOR,
+                '<?xml version="1.0"?>
+                <psalm
+                    errorLevel="6"
+                    runTaintAnalysis="true"
+                >
+                    <projectFiles>
+                        <directory name="src" />
+                    </projectFiles>
+                    <plugins>
+                        <plugin filename="examples/plugins/SafeArrayKeyChecker.php" />
+                    </plugins>
+                </psalm>',
+            ),
+        );
+
+        $this->project_analyzer->getCodebase()->config->initializePlugins($this->project_analyzer);
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            /**
+             * @psalm-taint-sink html $build
+             */
+            function output(array $build) {}
+
+            $build = [
+                "nested" => [
+                    "safe_key" => $_GET["input"],
+                ],
+            ];
+            output($build);',
+        );
+
+        $this->project_analyzer->trackTaintedInputs();
+
+        $this->analyzeFile($file_path, new Context());
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            /**
+             * @psalm-taint-sink html $build
+             */
+            function output(array $build) {}
+
+            $build = [
+                "nested" => [
+                    "safe_key" => $_GET["input"],
+                    "a" => $_GET["input"],
+                ],
+            ];
+            output($build);',
+        );
+
+        $this->project_analyzer->trackTaintedInputs();
+
+        $this->expectException(CodeException::class);
+        $this->expectExceptionMessageMatches('/TaintedHtml/');
+
+        $this->analyzeFile($file_path, new Context());
+    }
+}
diff --git a/tests/Config/PluginTest.php b/tests/Config/PluginTest.php
index 0ba40e9d83e..da4a0dc798a 100644
--- a/tests/Config/PluginTest.php
+++ b/tests/Config/PluginTest.php
@@ -923,6 +923,64 @@ function b(int $e): int { return $e; }
         $this->analyzeFile($file_path, new Context());
     }
 
+    public function testAddTaints(): void
+    {
+        $this->project_analyzer = $this->getProjectAnalyzerWithConfig(
+            TestConfig::loadFromXML(
+                dirname(__DIR__, 2) . DIRECTORY_SEPARATOR,
+                '<?xml version="1.0"?>
+                <psalm
+                    errorLevel="6"
+                    runTaintAnalysis="true"
+                >
+                    <projectFiles>
+                        <directory name="src" />
+                    </projectFiles>
+                    <plugins>
+                        <plugin filename="examples/plugins/TaintActiveRecords.php" />
+                    </plugins>
+                </psalm>',
+            ),
+        );
+
+        $this->project_analyzer->getCodebase()->config->initializePlugins($this->project_analyzer);
+
+        $file_path = (string) getcwd() . '/src/somefile.php';
+
+        $this->addFile(
+            $file_path,
+            '<?php // --taint-analysis
+
+            namespace app\models;
+
+            class User {
+                public $name;
+
+                /**
+                 * @psalm-return list<User>
+                 */
+                public static function findAll(): array {
+                    $mockUser = new self();
+                    $mockUser->name = "<h1>Micky Mouse</h1>";
+
+                    return [$mockUser];
+                }
+            }
+
+            foreach (User::findAll() as $user) {
+                echo $user->name;
+            }
+            ',
+        );
+
+        $this->project_analyzer->trackTaintedInputs();
+
+        $this->expectException(CodeException::class);
+        $this->expectExceptionMessageMatches('/TaintedHtml/');
+
+        $this->analyzeFile($file_path, new Context());
+    }
+
     public function testRemoveTaints(): void
     {
         $this->project_analyzer = $this->getProjectAnalyzerWithConfig(