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(