Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions src/Psalm/Internal/Codebase/TaintFlowGraph.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace Psalm\Tests\Config\Plugin\EventHandler\LocationlessNode;

use Override;
use Psalm\Config;
use Psalm\Context;
use Psalm\Exception\CodeException;
use Psalm\Internal\Analyzer\ProjectAnalyzer;
use Psalm\Internal\IncludeCollector;
use Psalm\Internal\Provider\Providers;
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;

final class LocationlessNodeTest extends TestCase
{
#[Override]
public static function setUpBeforeClass(): void
{
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());
$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,
'<?xml version="1.0"?>
<psalm
errorLevel="6"
runTaintAnalysis="true"
>
<projectFiles>
<directory name="src" />
</projectFiles>
<issueHandlers>
<MissingPureAnnotation errorLevel="suppress"/>
<ImpureFunctionCall errorLevel="suppress"/>
</issueHandlers>
</psalm>',
),
);
$this->project_analyzer->getCodebase()->config->eventDispatcher->registerClass(RelayPlugin::class);

$file_path = (string) getcwd() . '/src/somefile.php';

$this->addFile(
$file_path,
'<?php // --taint-analysis

function relay(string $value): void {}
function deliver(): void {}

relay((string) $_GET["name"]);
deliver();
',
);

// 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->analyzeFile($file_path, new Context(), true, true);
}
}
53 changes: 53 additions & 0 deletions tests/Config/Plugin/EventHandler/LocationlessNode/RelayPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Psalm\Tests\Config\Plugin\EventHandler\LocationlessNode;

use Override;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use Psalm\CodeLocation;
use Psalm\Internal\DataFlow\DataFlowNode;
use Psalm\Plugin\EventHandler\AfterExpressionAnalysisInterface;
use Psalm\Plugin\EventHandler\Event\AfterExpressionAnalysisEvent;
use Psalm\Type\TaintKind;

/**
* Passes what is given to relay() to the output of deliver(), through a node with no location: like a template
* engine does with the variables of a template and its output.
*/
final class RelayPlugin implements AfterExpressionAnalysisInterface
{
#[Override]
public static function afterExpressionAnalysis(AfterExpressionAnalysisEvent $event): ?bool
{
$expr = $event->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;
}
}
Loading