From 4c4c638a0a7070150ce51842345217a843cd3a25 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Fri, 2 Oct 2026 19:55:42 +0200 Subject: [PATCH 1/2] Report the taint flows a plugin connects to a sink from a node without location --- .../Internal/Codebase/TaintFlowGraph.php | 20 ++-- .../LocationlessNode/LocationlessNodeTest.php | 105 ++++++++++++++++++ .../LocationlessNode/RelayPlugin.php | 53 +++++++++ 3 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 tests/Config/Plugin/EventHandler/LocationlessNode/LocationlessNodeTest.php create mode 100644 tests/Config/Plugin/EventHandler/LocationlessNode/RelayPlugin.php diff --git a/src/Psalm/Internal/Codebase/TaintFlowGraph.php b/src/Psalm/Internal/Codebase/TaintFlowGraph.php index c0a6162d781..c68bce4a89d 100644 --- a/src/Psalm/Internal/Codebase/TaintFlowGraph.php +++ b/src/Psalm/Internal/Codebase/TaintFlowGraph.php @@ -1213,7 +1213,9 @@ private function getChildNodes( continue; } - if ($sink !== null && $generated_source->code_location) { + // a flow is reported at its sink, or else at the node it reaches the sink from: a plugin can + // connect a node without a location to a sink + if ($sink !== null && ($generated_source->code_location || $sink->code_location)) { $matching_taints = $sink->taints & $new_taints; if ($matching_taints) { @@ -1332,7 +1334,13 @@ private function reportTaintedFlowOnce( Config $config, Codebase $codebase, ): void { - if ($predecessor->code_location === null) { + if ($sink->code_location + && $config->reportIssueInFile('TaintedInput', $sink->code_location->file_path) + ) { + $issue_location = $sink->code_location; + } elseif ($predecessor->code_location !== null) { + $issue_location = $predecessor->code_location; + } else { return; } @@ -1351,14 +1359,6 @@ private function reportTaintedFlowOnce( $this->reported_flows[$sink->id][$predecessor->id][$origin] = $reported_taints | $unreported_taints; - if ($sink->code_location - && $config->reportIssueInFile('TaintedInput', $sink->code_location->file_path) - ) { - $issue_location = $sink->code_location; - } else { - $issue_location = $predecessor->code_location; - } - $issue_trace = $this->getIssueTrace($predecessor); $path = $this->getPredecessorPath($predecessor) . ' -> ' . $this->getSuccessorPath($sink); diff --git a/tests/Config/Plugin/EventHandler/LocationlessNode/LocationlessNodeTest.php b/tests/Config/Plugin/EventHandler/LocationlessNode/LocationlessNodeTest.php new file mode 100644 index 00000000000..a34e37abe98 --- /dev/null +++ b/tests/Config/Plugin/EventHandler/LocationlessNode/LocationlessNodeTest.php @@ -0,0 +1,105 @@ +setIncludeCollector(new IncludeCollector()); + $p = new ProjectAnalyzer( + $config, + new Providers( + $this->file_provider, + new FakeParserCacheProvider(), + ), + new ReportOptions(), + ); + $p->initExtraFiles(); + $p->initProjectFiles(); + return $p; + } + + public function testFlowFromANodeWithoutLocationIsReportedAtTheSink(): void + { + $this->project_analyzer = $this->getProjectAnalyzerWithConfig( + TestConfig::loadFromXML( + dirname(__DIR__, 5) . DIRECTORY_SEPARATOR, + ' + + + + + + + + + + + + ', + ), + ); + $this->project_analyzer->getCodebase()->config->initializePlugins($this->project_analyzer); + + $file_path = (string) getcwd() . '/src/somefile.php'; + + $this->addFile( + $file_path, + 'expectException(CodeException::class); + $this->expectExceptionMessageMatches('/^TaintedHtml - src\/somefile.php:7:13/'); + + $this->analyzeFile($file_path, new Context(), true, true); + } +} diff --git a/tests/Config/Plugin/EventHandler/LocationlessNode/RelayPlugin.php b/tests/Config/Plugin/EventHandler/LocationlessNode/RelayPlugin.php new file mode 100644 index 00000000000..b905bcc994d --- /dev/null +++ b/tests/Config/Plugin/EventHandler/LocationlessNode/RelayPlugin.php @@ -0,0 +1,53 @@ +getExpr(); + $graph = $event->getCodebase()->taint_flow_graph; + + if ($graph === null || !$expr instanceof FuncCall || !$expr->name instanceof Name) { + return null; + } + + $relay = DataFlowNode::getForPropertyFetch('relay'); + $graph->addNode($relay); + + if ($expr->name->toLowerString() === 'relay' && isset($expr->getArgs()[0])) { + $type = $event->getStatementsSource()->getNodeTypeProvider()->getType($expr->getArgs()[0]->value); + foreach ($type?->parent_nodes ?? [] as $parent_node) { + $graph->addPath($parent_node, $relay, 'arg'); + } + } elseif ($expr->name->toLowerString() === 'deliver') { + $sink = DataFlowNode::getForTaint( + 'deliver', + new CodeLocation($event->getStatementsSource(), $expr), + TaintKind::INPUT_HTML, + ); + $graph->addNode($sink); + $graph->addSink($sink); + $graph->addPath($relay, $sink, 'arg'); + } + + return null; + } +} From 312f31dfa61820c378d9bdeeb475e95c09adbba5 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 4 Oct 2026 12:21:04 +0200 Subject: [PATCH 2/2] Register the test plugin by class and match the sink location on every platform --- .../LocationlessNode/LocationlessNodeTest.php | 9 ++------- .../Plugin/EventHandler/LocationlessNode/RelayPlugin.php | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/Config/Plugin/EventHandler/LocationlessNode/LocationlessNodeTest.php b/tests/Config/Plugin/EventHandler/LocationlessNode/LocationlessNodeTest.php index a34e37abe98..8144c5249cd 100644 --- a/tests/Config/Plugin/EventHandler/LocationlessNode/LocationlessNodeTest.php +++ b/tests/Config/Plugin/EventHandler/LocationlessNode/LocationlessNodeTest.php @@ -10,9 +10,7 @@ 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; @@ -70,9 +68,6 @@ public function testFlowFromANodeWithoutLocationIsReportedAtTheSink(): void - - - @@ -80,7 +75,7 @@ public function testFlowFromANodeWithoutLocationIsReportedAtTheSink(): void ', ), ); - $this->project_analyzer->getCodebase()->config->initializePlugins($this->project_analyzer); + $this->project_analyzer->getCodebase()->config->eventDispatcher->registerClass(RelayPlugin::class); $file_path = (string) getcwd() . '/src/somefile.php'; @@ -98,7 +93,7 @@ function deliver(): void {} // the flow reaches the sink from a node with no location: it is reported at the sink $this->expectException(CodeException::class); - $this->expectExceptionMessageMatches('/^TaintedHtml - src\/somefile.php:7:13/'); + $this->expectExceptionMessageMatches('#^TaintedHtml - (.*[\\\\/])?src[\\\\/]somefile\.php:7:13#'); $this->analyzeFile($file_path, new Context(), true, true); } diff --git a/tests/Config/Plugin/EventHandler/LocationlessNode/RelayPlugin.php b/tests/Config/Plugin/EventHandler/LocationlessNode/RelayPlugin.php index b905bcc994d..5acd01a1e3b 100644 --- a/tests/Config/Plugin/EventHandler/LocationlessNode/RelayPlugin.php +++ b/tests/Config/Plugin/EventHandler/LocationlessNode/RelayPlugin.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Psalm\Example\Plugin; +namespace Psalm\Tests\Config\Plugin\EventHandler\LocationlessNode; use Override; use PhpParser\Node\Expr\FuncCall;