├── .github └── workflows │ └── tests.yaml ├── README.md ├── composer.json ├── phpstan-safe-rule.neon └── src ├── Rules ├── Error │ ├── SafeClassRuleError.php │ ├── SafeFunctionRuleError.php │ └── SafeRuleError.php ├── UseSafeCallablesRule.php ├── UseSafeClassesRule.php └── UseSafeFunctionsRule.php ├── Type └── Php │ ├── PregMatchParameterOutTypeExtension.php │ ├── PregMatchTypeSpecifyingExtension.php │ └── ReplaceSafeFunctionsDynamicReturnTypeExtension.php └── Utils ├── ClassListLoader.php └── FunctionListLoader.php /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: ~ 5 | push: 6 | branches: 7 | - master 8 | schedule: 9 | - cron: "47 6 * * 1" # once a month, to surface issues with newer dependencies 10 | 11 | jobs: 12 | Tests: 13 | runs-on: "ubuntu-latest" 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | php: 18 | - "8.1" 19 | - "8.2" 20 | - "8.3" 21 | - "8.4" 22 | dependencies: 23 | - "highest" 24 | include: 25 | - description: "(lowest)" 26 | php: "8.1" 27 | dependencies: "lowest" 28 | 29 | name: PHP ${{ matrix.php }} ${{ matrix.description }} 30 | steps: 31 | - name: "Checkout" 32 | uses: actions/checkout@v4 33 | - name: "Install PHP" 34 | uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: ${{ matrix.php }} 37 | coverage: xdebug 38 | - name: "Install dependencies" 39 | uses: ramsey/composer-install@v3 40 | with: 41 | dependency-versions: ${{ matrix.dependencies }} 42 | - name: "Run PHPStan analysis" 43 | run: composer phpstan 44 | - name: "Run tests" 45 | run: vendor/bin/phpunit --coverage-clover=coverage.xml --colors=always 46 | - name: "Upload test coverage" 47 | uses: codecov/codecov-action@v5 48 | with: 49 | files: coverage.xml 50 | fail_ci_if_error: true 51 | env: 52 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Stable Version](https://poser.pugx.org/thecodingmachine/phpstan-safe-rule/v/stable)](https://packagist.org/packages/thecodingmachine/phpstan-safe-rule) 2 | [![Total Downloads](https://poser.pugx.org/thecodingmachine/phpstan-safe-rule/downloads)](https://packagist.org/packages/thecodingmachine/phpstan-safe-rule) 3 | [![Latest Unstable Version](https://poser.pugx.org/thecodingmachine/phpstan-safe-rule/v/unstable)](https://packagist.org/packages/thecodingmachine/phpstan-safe-rule) 4 | [![License](https://poser.pugx.org/thecodingmachine/phpstan-safe-rule/license)](https://packagist.org/packages/thecodingmachine/phpstan-safe-rule) 5 | [![Build Status](https://travis-ci.org/thecodingmachine/phpstan-safe-rule.svg?branch=master)](https://travis-ci.org/thecodingmachine/phpstan-safe-rule) 6 | [![Coverage Status](https://coveralls.io/repos/thecodingmachine/phpstan-safe-rule/badge.svg?branch=master&service=github)](https://coveralls.io/github/thecodingmachine/phpstan-safe-rule?branch=master) 7 | 8 | 9 | PHPStan rules for thecodingmachine/safe 10 | ======================================= 11 | 12 | The [thecodingmachine/safe](https://github.com/thecodingmachine/safe) package provides a set of core PHP functions rewritten to throw exceptions instead of returning `false` when an error is encountered. 13 | 14 | This PHPStan rule will help you detect unsafe function call and will propose you to use the `thecodingmachine/safe` variant instead. 15 | 16 | Please read [thecodingmachine/safe documentation](https://github.com/thecodingmachine/safe) for details about installation and usage. 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thecodingmachine/phpstan-safe-rule", 3 | "description": "A PHPStan rule to detect safety issues. Must be used in conjunction with thecodingmachine/safe", 4 | "type": "phpstan-extension", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "David Négrier", 9 | "email": "d.negrier@thecodingmachine.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "phpstan/phpstan": "^2.1.11", 15 | "thecodingmachine/safe": "^1.2 || ^2.0 || ^3.0", 16 | "nikic/php-parser": "^5" 17 | }, 18 | "require-dev": { 19 | "phpunit/phpunit": "^10.4", 20 | "php-coveralls/php-coveralls": "^2.1", 21 | "squizlabs/php_codesniffer": "^3.4" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "TheCodingMachine\\Safe\\PHPStan\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "TheCodingMachine\\Safe\\PHPStan\\": "tests/" 31 | } 32 | }, 33 | "scripts": { 34 | "phpstan": "phpstan analyse -c phpstan.neon --no-progress -vvv", 35 | "test": "XDEBUG_MODE=coverage phpunit", 36 | "cs-fix": "phpcbf", 37 | "cs-check": "phpcs" 38 | }, 39 | "extra": { 40 | "branch-alias": { 41 | "dev-master": "2.0-dev" 42 | }, 43 | "phpstan": { 44 | "includes": [ 45 | "phpstan-safe-rule.neon" 46 | ] 47 | } 48 | }, 49 | "minimum-stability": "dev", 50 | "prefer-stable": true 51 | } 52 | -------------------------------------------------------------------------------- /phpstan-safe-rule.neon: -------------------------------------------------------------------------------- 1 | services: 2 | - 3 | class: TheCodingMachine\Safe\PHPStan\Rules\UseSafeCallablesRule 4 | tags: 5 | - phpstan.rules.rule 6 | - 7 | class: TheCodingMachine\Safe\PHPStan\Rules\UseSafeFunctionsRule 8 | tags: 9 | - phpstan.rules.rule 10 | - 11 | class: TheCodingMachine\Safe\PHPStan\Rules\UseSafeClassesRule 12 | tags: 13 | - phpstan.rules.rule 14 | - 15 | class: TheCodingMachine\Safe\PHPStan\Type\Php\ReplaceSafeFunctionsDynamicReturnTypeExtension 16 | tags: 17 | - phpstan.broker.dynamicFunctionReturnTypeExtension 18 | - 19 | class: TheCodingMachine\Safe\PHPStan\Type\Php\PregMatchParameterOutTypeExtension 20 | tags: 21 | - phpstan.functionParameterOutTypeExtension 22 | - 23 | class: TheCodingMachine\Safe\PHPStan\Type\Php\PregMatchTypeSpecifyingExtension 24 | tags: 25 | - phpstan.typeSpecifier.functionTypeSpecifyingExtension 26 | -------------------------------------------------------------------------------- /src/Rules/Error/SafeClassRuleError.php: -------------------------------------------------------------------------------- 1 | toString(); 12 | 13 | parent::__construct( 14 | "Function $functionName is unsafe to use. It can return FALSE instead of throwing an exception. Please add 'use function Safe\\$functionName;' at the beginning of the file to use the variant provided by the 'thecodingmachine/safe' library.", 15 | $line, 16 | ); 17 | } 18 | 19 | public function getIdentifier(): string 20 | { 21 | return self::IDENTIFIER_PREFIX . 'function'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Rules/Error/SafeRuleError.php: -------------------------------------------------------------------------------- 1 | message = $message; 19 | $this->line = $line; 20 | } 21 | 22 | public function getMessage(): string 23 | { 24 | return $this->message; 25 | } 26 | 27 | public function getLine(): int 28 | { 29 | return $this->line; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Rules/UseSafeCallablesRule.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class UseSafeCallablesRule implements Rule 18 | { 19 | /** 20 | * @see JSON_THROW_ON_ERROR 21 | */ 22 | const JSON_THROW_ON_ERROR = 4194304; 23 | 24 | public function getNodeType(): string 25 | { 26 | return FunctionCallableNode::class; 27 | } 28 | 29 | public function processNode(Node $node, Scope $scope): array 30 | { 31 | $nodeName = $node->getName(); 32 | if (!$nodeName instanceof Node\Name) { 33 | return []; 34 | } 35 | $functionName = $nodeName->toString(); 36 | $unsafeFunctions = FunctionListLoader::getFunctionList(); 37 | 38 | if (isset($unsafeFunctions[$functionName])) { 39 | return [new SafeFunctionRuleError($nodeName, $node->getStartLine())]; 40 | } 41 | 42 | return []; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Rules/UseSafeClassesRule.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class UseSafeClassesRule implements Rule 18 | { 19 | public function getNodeType(): string 20 | { 21 | return Node\Expr\New_::class; 22 | } 23 | 24 | public function processNode(Node $node, Scope $scope): array 25 | { 26 | $classNode = $node->class; 27 | if (!$classNode instanceof Node\Name) { 28 | return []; 29 | } 30 | 31 | $className = $classNode->toString(); 32 | $unsafeClasses = ClassListLoader::getClassList(); 33 | 34 | if (isset($unsafeClasses[$className])) { 35 | return [ 36 | new SafeClassRuleError($classNode, $node->getStartLine()), 37 | ]; 38 | } 39 | 40 | return []; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Rules/UseSafeFunctionsRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class UseSafeFunctionsRule implements Rule 20 | { 21 | /** 22 | * @see JSON_THROW_ON_ERROR 23 | */ 24 | const JSON_THROW_ON_ERROR = 4194304; 25 | 26 | public function getNodeType(): string 27 | { 28 | return Node\Expr\FuncCall::class; 29 | } 30 | 31 | public function processNode(Node $node, Scope $scope): array 32 | { 33 | $nodeName = $node->name; 34 | if (!$nodeName instanceof Node\Name) { 35 | return []; 36 | } 37 | $functionName = $nodeName->toString(); 38 | $unsafeFunctions = FunctionListLoader::getFunctionList(); 39 | 40 | if (isset($unsafeFunctions[$functionName])) { 41 | if ($functionName === "json_decode" || $functionName === "json_encode") { 42 | foreach ($node->args as $arg) { 43 | if ($arg instanceof Node\Arg && 44 | $arg->name instanceof Node\Identifier && 45 | $arg->name->toLowerString() === "flags" 46 | ) { 47 | if ($this->argValueIncludeJSONTHROWONERROR($arg)) { 48 | return []; 49 | } 50 | } 51 | } 52 | } 53 | 54 | if ($functionName === "json_decode" 55 | && $this->argValueIncludeJSONTHROWONERROR($node->getArgs()[3] ?? null) 56 | ) { 57 | return []; 58 | } 59 | 60 | if ($functionName === "json_encode" 61 | && $this->argValueIncludeJSONTHROWONERROR($node->getArgs()[1] ?? null) 62 | ) { 63 | return []; 64 | } 65 | 66 | return [new SafeFunctionRuleError($nodeName, $node->getStartLine())]; 67 | } 68 | 69 | return []; 70 | } 71 | 72 | private function argValueIncludeJSONTHROWONERROR(?Arg $arg): bool 73 | { 74 | if ($arg === null) { 75 | return false; 76 | } 77 | 78 | $parseValue = static function ($expr, array $options) use (&$parseValue): array { 79 | if ($expr instanceof Expr\BinaryOp\BitwiseOr) { 80 | return array_merge($parseValue($expr->left, $options), $parseValue($expr->right, $options)); 81 | } elseif ($expr instanceof Expr\ConstFetch) { 82 | return array_merge($options, $expr->name->getParts()); 83 | } elseif ($expr instanceof Scalar\Int_) { 84 | return array_merge($options, [$expr->value]); 85 | } else { 86 | return $options; 87 | } 88 | }; 89 | $options = $parseValue($arg->value, []); 90 | 91 | if (in_array("JSON_THROW_ON_ERROR", $options, true)) { 92 | return true; 93 | } 94 | 95 | $intOptions = array_filter($options, function (mixed $option): bool { 96 | return is_int($option); 97 | }); 98 | 99 | foreach ($intOptions as $option) { 100 | if (($option & self::JSON_THROW_ON_ERROR) === self::JSON_THROW_ON_ERROR) { 101 | return true; 102 | } 103 | } 104 | 105 | return false; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Type/Php/PregMatchParameterOutTypeExtension.php: -------------------------------------------------------------------------------- 1 | getName(), ['Safe\preg_match', 'Safe\preg_match_all'], true) 31 | // the parameter is named different, depending on PHP version. 32 | && in_array($parameter->getName(), ['subpatterns', 'matches'], true); 33 | } 34 | 35 | public function getParameterOutTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, ParameterReflection $parameter, Scope $scope): ?Type 36 | { 37 | $args = $funcCall->getArgs(); 38 | $patternArg = $args[0] ?? null; 39 | $matchesArg = $args[2] ?? null; 40 | $flagsArg = $args[3] ?? null; 41 | 42 | if ($patternArg === null || $matchesArg === null 43 | ) { 44 | return null; 45 | } 46 | 47 | $flagsType = null; 48 | if ($flagsArg !== null) { 49 | $flagsType = $scope->getType($flagsArg->value); 50 | } 51 | 52 | if ($functionReflection->getName() === 'Safe\preg_match') { 53 | return $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); 54 | } 55 | return $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Type/Php/PregMatchTypeSpecifyingExtension.php: -------------------------------------------------------------------------------- 1 | typeSpecifier = $typeSpecifier; 34 | } 35 | 36 | public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool 37 | { 38 | return in_array(strtolower($functionReflection->getName()), ['safe\preg_match', 'safe\preg_match_all'], true) && !$context->null(); 39 | } 40 | 41 | public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes 42 | { 43 | $args = $node->getArgs(); 44 | $patternArg = $args[0] ?? null; 45 | $matchesArg = $args[2] ?? null; 46 | $flagsArg = $args[3] ?? null; 47 | 48 | if ($patternArg === null || $matchesArg === null 49 | ) { 50 | return new SpecifiedTypes(); 51 | } 52 | 53 | $flagsType = null; 54 | if ($flagsArg !== null) { 55 | $flagsType = $scope->getType($flagsArg->value); 56 | } 57 | 58 | if ($functionReflection->getName() === 'Safe\preg_match') { 59 | $matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); 60 | } else { 61 | $matchedType = $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); 62 | } 63 | if ($matchedType === null) { 64 | return new SpecifiedTypes(); 65 | } 66 | 67 | $overwrite = false; 68 | if ($context->false()) { 69 | $overwrite = true; 70 | $context = $context->negate(); 71 | } 72 | 73 | $types = $this->typeSpecifier->create( 74 | $matchesArg->value, 75 | $matchedType, 76 | $context, 77 | $scope, 78 | )->setRootExpr($node); 79 | if ($overwrite) { 80 | $types = $types->setAlwaysOverwriteTypes(); 81 | } 82 | 83 | return $types; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Type/Php/ReplaceSafeFunctionsDynamicReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | */ 23 | private array $functions = [ 24 | 'Safe\preg_replace' => 2, 25 | ]; 26 | 27 | public function isFunctionSupported(FunctionReflection $functionReflection): bool 28 | { 29 | return array_key_exists($functionReflection->getName(), $this->functions); 30 | } 31 | 32 | public function getTypeFromFunctionCall( 33 | FunctionReflection $functionReflection, 34 | FuncCall $functionCall, 35 | Scope $scope 36 | ): Type { 37 | $type = $this->getPreliminarilyResolvedTypeFromFunctionCall($functionReflection, $functionCall, $scope); 38 | 39 | $possibleTypes = ParametersAcceptorSelector::selectFromArgs( 40 | $scope, 41 | $functionCall->getArgs(), 42 | $functionReflection->getVariants() 43 | ) 44 | ->getReturnType(); 45 | 46 | if (TypeCombinator::containsNull($possibleTypes)) { 47 | $type = TypeCombinator::addNull($type); 48 | } 49 | 50 | return $type; 51 | } 52 | 53 | private function getPreliminarilyResolvedTypeFromFunctionCall( 54 | FunctionReflection $functionReflection, 55 | FuncCall $functionCall, 56 | Scope $scope 57 | ): Type { 58 | $argumentPosition = $this->functions[$functionReflection->getName()]; 59 | 60 | $args = $functionCall->getArgs(); 61 | $variants = $functionReflection->getVariants(); 62 | $defaultReturnType = ParametersAcceptorSelector::selectFromArgs($scope, $args, $variants) 63 | ->getReturnType(); 64 | 65 | if (count($args) <= $argumentPosition) { 66 | return $defaultReturnType; 67 | } 68 | 69 | $subjectArgument = $args[$argumentPosition]; 70 | $subjectArgumentType = $scope->getType($subjectArgument->value); 71 | $mixedType = new MixedType(); 72 | if ($subjectArgumentType->isSuperTypeOf($mixedType)->yes()) { 73 | return TypeUtils::toBenevolentUnion($defaultReturnType); 74 | } 75 | 76 | $stringType = new StringType(); 77 | if ($stringType->isSuperTypeOf($subjectArgumentType)->yes()) { 78 | return $stringType; 79 | } 80 | 81 | $arrayType = new ArrayType($mixedType, $mixedType); 82 | if ($arrayType->isSuperTypeOf($subjectArgumentType)->yes()) { 83 | return $arrayType; 84 | } 85 | 86 | return $defaultReturnType; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Utils/ClassListLoader.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | private static $classes = [ 16 | 'DateTime' => 'DateTime', 17 | 'DateTimeImmutable' => 'DateTimeImmutable', 18 | ]; 19 | 20 | /** 21 | * @return class-string[] 22 | */ 23 | public static function getClassList(): array 24 | { 25 | return self::$classes; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Utils/FunctionListLoader.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | private static array $functions; 12 | 13 | /** 14 | * @return array 15 | */ 16 | public static function getFunctionList(): array 17 | { 18 | return self::$functions ??= self::fetchIndexedFunctions(); 19 | } 20 | 21 | /** 22 | * @return array 23 | */ 24 | private static function fetchIndexedFunctions(): array 25 | { 26 | if (\file_exists(__DIR__ . '/../../../safe/generated/functionsList.php')) { 27 | $functions = require __DIR__ . '/../../../safe/generated/functionsList.php'; 28 | } elseif (\file_exists(__DIR__ . '/../../vendor/thecodingmachine/safe/generated/functionsList.php')) { 29 | $functions = require __DIR__ . '/../../vendor/thecodingmachine/safe/generated/functionsList.php'; 30 | } else { 31 | throw new \RuntimeException('Could not find thecodingmachine/safe\'s functionsList.php file.'); 32 | } 33 | 34 | if (!is_array($functions)) { 35 | throw new \RuntimeException('The functions list should be an array.'); 36 | } 37 | 38 | $indexedFunctions = []; 39 | 40 | foreach ($functions as $function) { 41 | if (!is_string($function)) { 42 | throw new \RuntimeException('The functions list should contain only strings, got ' . get_debug_type($function)); 43 | } 44 | 45 | // Let's index these functions by their name 46 | $indexedFunctions[$function] = $function; 47 | } 48 | 49 | return $indexedFunctions; 50 | } 51 | } 52 | --------------------------------------------------------------------------------