Skip to content

Commit

Permalink
Add a PHPStan rule to detect invalid patterns passed to composer/pcre…
Browse files Browse the repository at this point in the history
… methods
  • Loading branch information
Seldaek committed Jul 18, 2024
1 parent 5b103b3 commit ab3dc17
Show file tree
Hide file tree
Showing 8 changed files with 338 additions and 13 deletions.
1 change: 1 addition & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ services:

rules:
- Composer\Pcre\PHPStan\UnsafeStrictGroupsCallRule
- Composer\Pcre\PHPStan\InvalidRegexPatternRule
90 changes: 90 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,93 @@ parameters:
message: "#^Parameter &\\$matches @param\\-out type of method Composer\\\\Pcre\\\\Preg\\:\\:matchWithOffsets\\(\\) expects array\\<int\\|string, array\\{string\\|null, int\\<\\-1, max\\>\\}\\>, array\\<int\\|string, string\\|null\\> given\\.$#"
count: 1
path: src/Preg.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/GrepTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/IsMatchAllTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/IsMatchAllWithOffsetsTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/IsMatchTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/IsMatchWithOffsetsTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/MatchAllTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/MatchTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/ReplaceCallbackArrayTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/ReplaceCallbackTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/ReplaceTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/SplitTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/SplitWithOffsetsTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/IsMatchTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/MatchAllTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/MatchTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/ReplaceCallbackArrayTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/ReplaceCallbackTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/ReplaceTest.php
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ parameters:

excludePaths:
- tests/PHPStanTests/nsrt/*
- tests/PHPStanTests/fixtures/*

includes:
- extension.neon
Expand Down
142 changes: 142 additions & 0 deletions src/PHPStan/InvalidRegexPatternRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php declare(strict_types = 1);

namespace Composer\Pcre\PHPStan;

use Composer\Pcre\Preg;
use Composer\Pcre\Regex;
use Composer\Pcre\PcreException;
use Nette\Utils\RegexpException;
use Nette\Utils\Strings;
use PhpParser\Node;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use function in_array;
use function sprintf;

/**
* Copy of PHPStan's RegularExpressionPatternRule
*
* @implements Rule<StaticCall>
*/
class InvalidRegexPatternRule implements Rule
{
public function getNodeType(): string
{
return StaticCall::class;
}

public function processNode(Node $node, Scope $scope): array
{
$patterns = $this->extractPatterns($node, $scope);

$errors = [];
foreach ($patterns as $pattern) {
$errorMessage = $this->validatePattern($pattern);
if ($errorMessage === null) {
continue;
}

$errors[] = RuleErrorBuilder::message(sprintf('Regex pattern is invalid: %s', $errorMessage))->identifier('regexp.pattern')->build();
}

return $errors;
}

/**
* @return string[]
*/
private function extractPatterns(StaticCall $node, Scope $scope): array
{
if (!$node->class instanceof FullyQualified) {
return [];
}
$isRegex = $node->class->toString() === Regex::class;
$isPreg = $node->class->toString() === Preg::class;
if (!$isRegex && !$isPreg) {
return [];
}
if (!$node->name instanceof Node\Identifier || !Preg::isMatch('{^(match|isMatch|grep|replace|split)}', $node->name->name)) {
return [];
}

$functionName = $node->name->name;
if (!isset($node->getArgs()[0])) {
return [];
}

$patternNode = $node->getArgs()[0]->value;
$patternType = $scope->getType($patternNode);

$patternStrings = [];

foreach ($patternType->getConstantStrings() as $constantStringType) {
if ($functionName === 'replaceCallbackArray') {
continue;
}

$patternStrings[] = $constantStringType->getValue();
}

foreach ($patternType->getConstantArrays() as $constantArrayType) {
if (
in_array($functionName, [
'replace',
'replaceCallback',
], true)
) {
foreach ($constantArrayType->getValueTypes() as $arrayKeyType) {
foreach ($arrayKeyType->getConstantStrings() as $constantString) {
$patternStrings[] = $constantString->getValue();
}
}
}

if ($functionName !== 'replaceCallbackArray') {
continue;
}

foreach ($constantArrayType->getKeyTypes() as $arrayKeyType) {
foreach ($arrayKeyType->getConstantStrings() as $constantString) {
$patternStrings[] = $constantString->getValue();
}
}
}

return $patternStrings;
}

private function validatePattern(string $pattern): ?string
{
try {
$msg = null;
$prev = set_error_handler(function (int $severity, string $message, string $file) use (&$msg): bool {
$msg = preg_replace("#^preg_match(_all)?\\(.*?\\): #", '', $message);

return true;
});

if ($pattern === '') {
return 'Empty string is not a valid regular expression';
}

Preg::match($pattern, '');
if ($msg !== null) {
return $msg;
}
} catch (PcreException $e) {
if ($e->getCode() === PREG_INTERNAL_ERROR && $msg !== null) {
return $msg;
}

return preg_replace('{.*? failed executing ".*": }', '', $e->getMessage());
} finally {
restore_error_handler();
}

return null;
}

}
69 changes: 69 additions & 0 deletions tests/PHPStanTests/InvalidRegexPatternRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

/*
* This file is part of composer/pcre.
*
* (c) Composer <https://github.com/composer>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Composer\Pcre\PHPStanTests;

use PHPStan\Testing\RuleTestCase;
use Composer\Pcre\PHPStan\InvalidRegexPatternRule;
use PHPStan\Type\Php\RegexArrayShapeMatcher;

/**
* Run with "vendor/bin/phpunit --testsuite phpstan"
*
* This is excluded by default to avoid side effects with the library tests
*
* @extends RuleTestCase<InvalidRegexPatternRule>
*/
class InvalidRegexPatternRuleTest extends RuleTestCase
{
protected function getRule(): \PHPStan\Rules\Rule
{
return new InvalidRegexPatternRule();
}

public function testRule(): void
{
$this->analyse([__DIR__ . '/fixtures/invalid-patterns.php'], [
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1',
11,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1',
13,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1',
15,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1',
17,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1',
19,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1',
21,
],
]);
}

public static function getAdditionalConfigFiles(): array
{
return [
'phar://' . __DIR__ . '/../../vendor/phpstan/phpstan/phpstan.phar/conf/bleedingEdge.neon',
__DIR__ . '/../../extension.neon',
];
}
}
2 changes: 1 addition & 1 deletion tests/PHPStanTests/UnsafeStrictGroupsCallRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
*
* @extends RuleTestCase<UnsafeStrictGroupsCallRule>
*/
class UnsafeStrictGruopsCallRuleTest extends RuleTestCase
class UnsafeStrictGroupsCallRuleTest extends RuleTestCase
{
protected function getRule(): \PHPStan\Rules\Rule
{
Expand Down
22 changes: 22 additions & 0 deletions tests/PHPStanTests/fixtures/invalid-patterns.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace PregMatchShapes;

use Composer\Pcre\Preg;
use Composer\Pcre\Regex;
use function PHPStan\Testing\assertType;

function doMatch(string $s): void
{
Preg::match('/(/i', $s, $matches);

Regex::isMatch('/(/i', $s);

Preg::grep('/(/i', [$s]);

Preg::replaceCallback('/(/i', function ($match) { return ''; }, $s);

Preg::replaceCallback(['/(/i', '{}'], function ($match) { return ''; }, $s);

Preg::replaceCallbackArray(['/(/i' => function ($match) { return ''; }], $s);
}
Loading

0 comments on commit ab3dc17

Please sign in to comment.