diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 859a3a0..f600128 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ '7.3', '7.4', '8.0' ] + php: [ '8.1', '8.2', '8.3' ] steps: - uses: actions/checkout@v2 - name: Setup PHP with tools diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5aecb..30f5283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ master * todo... +v2.0.0 +------ + +* Drop support for PHP ^7.3 || ^8.0, support now only ^8.1 +* Force usage of native sprintf function +* Added rule to ban shell execution via backticks +* Added rule to ban print statements +* Allow Composer plugin ergebnis/composer-normalize +* Add Composer keyword for asking user to add this package to require-dev instead of require +* Normalize leading backslashes in banned function names + v1.0.0 ------ diff --git a/README.md b/README.md index 0664579..03642a6 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,16 @@ parameters: - system - var_dump + # enable detection of print statements + - + type: Expr_Print + functions: null + + # enable detection of shell execution by backticks + - + type: Expr_ShellExec + functions: null + # enable detection of `use Tests\Foo\Bar` in a non-test file use_from_tests: true ``` diff --git a/composer.json b/composer.json index 69f3c49..91c18ce 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,13 @@ { "name": "ekino/phpstan-banned-code", - "type": "phpstan-extension", "description": "Detected banned code using PHPStan", + "license": "MIT", + "type": "phpstan-extension", "keywords": [ "PHPStan", - "Code Quality" + "Code Quality", + "static analysis" ], - "homepage": "https://github.com/ekino/phpstan-banned-code", - "license": "MIT", "authors": [ { "name": "Rémi Marseille", @@ -15,8 +15,9 @@ "homepage": "https://www.ekino.com" } ], + "homepage": "https://github.com/ekino/phpstan-banned-code", "require": { - "php": "^7.3 || ^8.0", + "php": "^8.1", "phpstan/phpstan": "^1.0" }, "require-dev": { @@ -27,16 +28,8 @@ "phpunit/phpunit": "^9.5", "symfony/var-dumper": "^5.0" }, - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - }, - "phpstan": { - "includes": [ - "extension.neon" - ] - } - }, + "minimum-stability": "dev", + "prefer-stable": true, "autoload": { "psr-4": { "Ekino\\PHPStanBannedCode\\": "src" @@ -47,6 +40,19 @@ "Tests\\Ekino\\PHPStanBannedCode\\": "tests" } }, - "minimum-stability": "dev", - "prefer-stable": true + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + } } diff --git a/extension.neon b/extension.neon index 00c5ecb..1ebe93b 100644 --- a/extension.neon +++ b/extension.neon @@ -41,6 +41,16 @@ parameters: - system - var_dump + # enable detection of print statements + - + type: Expr_Print + functions: null + + # enable detection of shell execution by backticks + - + type: Expr_ShellExec + functions: null + # enable detection of `use Tests\Foo\Bar` in a non-test file use_from_tests: true diff --git a/snippets/backticks.php b/snippets/backticks.php new file mode 100644 index 0000000..50b7664 --- /dev/null +++ b/snippets/backticks.php @@ -0,0 +1,3 @@ +> + * @var array */ private $bannedNodes; /** - * @param array> $bannedNodes + * @var array + */ + private $bannedFunctions; + + /** + * @param array}> $bannedNodes */ public function __construct(array $bannedNodes) { - $this->bannedNodes = array_column($bannedNodes, null, 'type'); + $this->bannedNodes = array_column($bannedNodes, null, 'type'); + $this->bannedFunctions = $this->normalizeFunctionNames($this->bannedNodes); } /** @@ -64,13 +70,31 @@ public function processNode(Node $node, Scope $scope): array $function = $node->name->toString(); - if (\in_array($function, $this->bannedNodes[$type]['functions'])) { - return [sprintf('Should not use function "%s", please change the code.', $function)]; + if (\in_array($function, $this->bannedFunctions)) { + return [\sprintf('Should not use function "%s", please change the code.', $function)]; } return []; } - return [sprintf('Should not use node with type "%s", please change the code.', $type)]; + return [\sprintf('Should not use node with type "%s", please change the code.', $type)]; + } + + /** + * Strip leading slashes from function names. + * + * php-parser makes the same normalization. + * + * @param array $bannedNodes + * @return array + */ + protected function normalizeFunctionNames(array $bannedNodes): array + { + return array_map( + static function (string $function): string { + return ltrim($function, '\\'); + }, + $bannedNodes['Expr_FuncCall']['functions'] ?? [] + ); } } diff --git a/src/Rules/BannedUseTestRule.php b/src/Rules/BannedUseTestRule.php index 2cc2253..865da36 100644 --- a/src/Rules/BannedUseTestRule.php +++ b/src/Rules/BannedUseTestRule.php @@ -62,14 +62,14 @@ public function processNode(Node $node, Scope $scope): array } if (!$node instanceof Use_) { - throw new \InvalidArgumentException(sprintf('$node must be an instance of %s, %s given', Use_::class, \get_class($node))); + throw new \InvalidArgumentException(\sprintf('$node must be an instance of %s, %s given', Use_::class, \get_class($node))); } $errors = []; foreach ($node->uses as $use) { if (preg_match('#^Tests#', $use->name->toString())) { - $errors[] = sprintf('Should not use %s in the non-test file %s', $use->name->toString(), $scope->getFile()); + $errors[] = \sprintf('Should not use %s in the non-test file %s', $use->name->toString(), $scope->getFile()); } } diff --git a/tests/Rules/BannedNodesRuleTest.php b/tests/Rules/BannedNodesRuleTest.php index 4a1294f..90dd794 100644 --- a/tests/Rules/BannedNodesRuleTest.php +++ b/tests/Rules/BannedNodesRuleTest.php @@ -20,8 +20,11 @@ use PhpParser\Node\Expr\Exit_; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Include_; +use PhpParser\Node\Expr\Print_; +use PhpParser\Node\Expr\ShellExec; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Name; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Scalar\LNumber; use PHPStan\Analyser\Scope; use PHPUnit\Framework\MockObject\MockObject; @@ -51,7 +54,9 @@ protected function setUp(): void ['type' => 'Stmt_Echo'], ['type' => 'Expr_Eval'], ['type' => 'Expr_Exit'], - ['type' => 'Expr_FuncCall', 'functions' => ['debug_backtrace', 'dump']], + ['type' => 'Expr_FuncCall', 'functions' => ['debug_backtrace', 'dump', 'Safe\namespaced']], + ['type' => 'Expr_Print'], + ['type' => 'Expr_ShellExec'], ]); $this->scope = $this->createMock(Scope::class); } @@ -76,27 +81,65 @@ public function testProcessNodeWithUnhandledType(Expr $node): void $this->assertCount(0, $this->rule->processNode($node, $this->scope)); } - /** - * Tests processNode with banned/allowed functions. - */ - public function testProcessNodeWithFunctions(): void + public function testProcessNodeWithBannedFunctions(): void + { + $ruleWithoutLeadingSlashes = new BannedNodesRule([ + [ + 'type' => 'Expr_FuncCall', + 'functions' => [ + 'root', + 'Safe\namespaced', + ] + ], + ]); + + $ruleWithLeadingSlashes = new BannedNodesRule([ + [ + 'type' => 'Expr_FuncCall', + 'functions' => [ + '\root', + '\Safe\namespaced', + ] + ], + ]); + + $rootFunction = new FuncCall(new Name('root')); + $this->assertNodeTriggersError($ruleWithoutLeadingSlashes, $rootFunction); + $this->assertNodeTriggersError($ruleWithLeadingSlashes, $rootFunction); + + $namespacedFunction = new FuncCall(new FullyQualified('Safe\namespaced')); + $this->assertNodeTriggersError($ruleWithoutLeadingSlashes, $namespacedFunction); + $this->assertNodeTriggersError($ruleWithLeadingSlashes, $namespacedFunction); + } + + protected function assertNodeTriggersError(BannedNodesRule $rule, Node $node): void { - foreach (['debug_backtrace', 'dump'] as $bannedFunction) { - $node = new FuncCall(new Name($bannedFunction)); + $this->assertCount(1, $rule->processNode($node, $this->scope)); + } - $this->assertCount(1, $this->rule->processNode($node, $this->scope)); - } + protected function assertNodePasses(BannedNodesRule $rule, Node $node): void + { + $this->assertCount(0, $rule->processNode($node, $this->scope)); + } - foreach (['array_search', 'sprintf'] as $allowedFunction) { - $node = new FuncCall(new Name($allowedFunction)); + public function testProcessNodeWithAllowedFunctions(): void + { + $rootFunction = new FuncCall(new Name('allowed')); + $this->assertNodePasses($this->rule, $rootFunction); - $this->assertCount(0, $this->rule->processNode($node, $this->scope)); - } + $namespacedFunction = new FuncCall(new FullyQualified('Safe\allowed')); + $this->assertNodePasses($this->rule, $namespacedFunction); + } + public function testProcessNodeWithFunctionInClosure(): void + { $node = new FuncCall(new Variable('myClosure')); - $this->assertCount(0, $this->rule->processNode($node, $this->scope)); + $this->assertNodePasses($this->rule, $node); + } + public function testProcessNodeWithArrayDimFetch(): void + { $node = new FuncCall( new Expr\ArrayDimFetch( new Variable('myArray'), @@ -104,7 +147,7 @@ public function testProcessNodeWithFunctions(): void ) ); - $this->assertCount(0, $this->rule->processNode($node, $this->scope)); + $this->assertNodePasses($this->rule, $node); } /** @@ -116,7 +159,7 @@ public function testProcessNodeWithFunctions(): void */ public function testProcessNodeWithHandledTypes(Expr $node): void { - $this->assertCount(1, $this->rule->processNode($node, $this->scope)); + $this->assertNodeTriggersError($this->rule, $node); } /** @@ -128,11 +171,13 @@ public function getUnhandledNodes(): \Generator } /** - * @return \Generator> + * @return \Generator> */ public function getHandledNodes(): \Generator { yield [new Eval_($this->createMock(Expr::class))]; yield [new Exit_()]; + yield [new Print_($this->createMock(Expr::class))]; + yield [new ShellExec([''])]; } }