Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
bd70371
HPB-4006 add `FormRequest` from vendor Laravel
Treggats Oct 3, 2024
aa4c010
HPB-4005 add stubs
Treggats Oct 3, 2024
4fdca15
HPB-4006 add test for using unvalidated data outside of a request class
Treggats Oct 3, 2024
c5f6722
HPB-4005 implement the rule
Treggats Oct 3, 2024
b6524ea
HPB-4006 add a controller class stub
Treggats Oct 4, 2024
ab806f2
HPB-4005 simplify checking the blacklist
Treggats Oct 3, 2024
a576dd9
HPB-4006 also check sub namespace
Treggats Oct 11, 2024
c544a03
HPB-4006 update minimum version PHPStan (1.11.0)
Treggats Oct 14, 2024
c7df77f
HPB-4006 simplify expected error message
Treggats Oct 14, 2024
acdb7a6
HPB-4006 update test name and separate regular request and form request
Treggats Oct 14, 2024
dc911f1
HPB-4006 update method names to better reflect its purpose
Treggats Oct 14, 2024
b5f0922
HPB-4006 merge illuminate & form request check
Treggats Oct 15, 2024
b7c590d
HPB-4006 check for chained allowed methods
Treggats Oct 15, 2024
09dbde9
HPB-4006 move to Validation namespace
Treggats Oct 15, 2024
368fccf
HPB-4006 introduce base class
Treggats Oct 15, 2024
082351d
HPB-4006 split form request and illuminate request rules
Treggats Oct 15, 2024
da7ca65
HPB-4006 update name for the illuminate request rule
Treggats Oct 15, 2024
7877685
HPB-4006 update name for the form request rule
Treggats Oct 15, 2024
38ceef0
HPB-4006 update name blacklist check
Treggats Oct 15, 2024
51b6479
HPB-4006 move name helper to the base class
Treggats Oct 15, 2024
3bbde4f
HPB-4006 remove redundant guard clause
Treggats Oct 15, 2024
4f76367
HPB-4006 remove redundant assertion
Treggats Oct 15, 2024
efe3ae8
HPB-4006 remove outdated class
Treggats Oct 15, 2024
b72726a
HPB-4006 add support for the "validated()" method
Treggats Oct 15, 2024
eac95d2
HPB-4006 cs & dataset update
Treggats Oct 15, 2024
96aefae
HPB-4006 replace illuminate packages with laravel/framework
Treggats Oct 16, 2024
0817eee
HPB-4006 remove illuminate stub class
Treggats Oct 16, 2024
8c7f99a
HPB-4006 reorder composer packages
Treggats Oct 16, 2024
7e0efbc
HPB-4006 extract namespace check
Treggats Oct 29, 2024
4ec6942
HPB-4006 move allowed methods to a config
Treggats Oct 30, 2024
d989686
HPB-4006 simplify mapping test stubs to namespace
Treggats Oct 31, 2024
ae6d2d7
HPB-4006 remove the FormRequest rule
Treggats Oct 31, 2024
d15e50d
HPB-4006 rename rule
Treggats Oct 31, 2024
5464b76
HPB-4006 add "usesValidMethod" method to check for allowed methods
Treggats Oct 31, 2024
992249c
HPB-4006 update getting the node name
Treggats Oct 31, 2024
61b014a
HPB-4006 solidify retrieving method parameter classnames
Treggats Oct 31, 2024
370e174
HPB-4006 handle anonymous classes
Treggats Oct 31, 2024
fe37273
HPB-4006 add additional info to the tip
Treggats Oct 31, 2024
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
14 changes: 10 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3]
laravel: [^10, ^11]
stability: [prefer-lowest, prefer-stable]
php:
- 8.2
- 8.3
laravel:
- ^10
- ^11
stability:
- prefer-lowest
- prefer-stable

name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }}

Expand All @@ -31,7 +37,7 @@

- name: Install dependencies
run: |
composer require "illuminate/support:${{ matrix.laravel }}" --no-interaction --no-update
composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replacing the illuminate packages with laravel/framework, as we need additional classes and it's easier to have one dependency

composer update --${{ matrix.stability }} --prefer-dist --no-interaction

- name: Run tests
Expand Down
15 changes: 6 additions & 9 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"license": "MIT",
"autoload": {
"psr-4": {
"Hihaho\\PhpstanRules\\": "src/"
"Hihaho\\PhpstanRules\\": "src/",
"App\\": "tests/stubs/App/"
}
},
"autoload-dev": {
Expand All @@ -23,20 +24,16 @@
"minimum-stability": "stable",
"require": {
"php": "^8.2",
"phpstan/phpstan": "^1.10.55",
"illuminate/support": "^10|^11",
"laravel/pint": "^1.13.9"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the PHPStan and Laravel Pint packages are used during development. So made more sense to have them in the require-dev

Copy link
Member

@RobertBoes RobertBoes Oct 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"illuminate/support": "^10|^11", would need to stay, as without it you'd be able to install the package in Laravel < 10 (or even, not in a Laravel app)
Could also change it for illuminate/contracts, not sure what the best one would be

"phpstan/phpstan": "^1.12"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"nikic/php-parser": "^5.0",
"spatie/invade": "^2.0",
"illuminate/http": "^10|^11",
"illuminate/console": "^10|^11",
"illuminate/mail": "^10|^11",
"illuminate/notifications": "^10|^11",
"illuminate/routing": "^10|^11",
"roave/security-advisories": "dev-latest"
"roave/security-advisories": "dev-latest",
"laravel/framework": "^10|^11",
"laravel/pint": "^1.18"
},
"scripts": {
"test": "phpunit",
Expand Down
27 changes: 27 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,31 @@
parametersSchema:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PHPStan requires a schema for the parameters used by the rule

scoperequestvalidatemethods: structure([
allowedRequestMethods: arrayOf(string()),
])

parameters:
scoperequestvalidatemethods:
allowedRequestMethods:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default valid validation methods, this can be appended by the local PHPStan config.

diff --git a/phpstan.neon b/phpstan.neon
index f011012500..ee07b64d03 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -50,6 +50,11 @@ parameters:
     # ignore the following error: Called 'Model::make()' which performs unnecessary work, use 'new Model()'.
     noModelMake: false
 
+    scoperequestvalidatemethods:
+        allowedRequestMethods:
+            - 'safe'
+            - 'validated'
+
     # Optional for having a clickable link to PHPStorm
     editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%'
 
`

- collect
- all
- only
- except
- input
- get
- keys
- string
- str
- integer
- float
- boolean

services:
-
class: Hihaho\PhpstanRules\Rules\Validation\ScopeRequestValidateMethods
arguments:
allowedRequestMethods: %scoperequestvalidatemethods.allowedRequestMethods%
tags:
- phpstan.rules.rule
-
class: Hihaho\PhpstanRules\Rules\NoInvadeInAppCode
tags:
Expand Down
65 changes: 65 additions & 0 deletions src/Rules/Validation/ScopeRequestValidateMethods.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php declare(strict_types=1);

namespace Hihaho\PhpstanRules\Rules\Validation;

use Illuminate\Http\Request as IlluminateRequest;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\ShouldNotHappenException;
use ReflectionException;

final class ScopeRequestValidateMethods extends ScopeValidationMethods
{
public function __construct(array $allowedRequestMethods)
{
$this->allowedRequestMethods = array_unique($allowedRequestMethods);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autowired by the PHPStan config. Currently there is no overriding, only appending to this list

}

public function getNodeType(): string
{
return MethodCall::class;
}

/**
* @phpstan-param MethodCall $node
* @throws ShouldNotHappenException | ReflectionException
*/
public function processNode(Node $node, Scope $scope): array
{
if ($this->hasValidNamespace($scope->getNamespace())) {
return [];
}

if (! $this->hasIlluminateRequestClass($scope)) {
return [];
}

if ($this->usesValidMethod(varName: $this->nodeName($node->var), methodName: $this->nodeName($node))) {
return [];
}

return [
RuleErrorBuilder::message(
'Usage of unvalidated request data is not allowed outside of App\\Http\\Requests'
)
->nonIgnorable()
->addTip('Use $request->safe() / $request->validated() to use request data')
->addTip(sprintf('Current checking: variable %s, method %s', $this->nodeName($node->var), $this->nodeName($node)))
->identifier('hihaho.request.unsafeRequestData')
->build(),
];
}

/** @throws ReflectionException */
private function hasIlluminateRequestClass(Scope $scope): bool
{
return collect($this->getClassMethods($scope))
->map(fn (array $method) => $this->getMethodParameterClassnames($method)
->filter(static fn (?string $fqn) => $fqn !== null)
->filter(static fn (string $fqn) => $fqn === IlluminateRequest::class)
)
->isNotEmpty();
}
}
156 changes: 156 additions & 0 deletions src/Rules/Validation/ScopeValidationMethods.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php declare(strict_types=1);

namespace Hihaho\PhpstanRules\Rules\Validation;

use Illuminate\Support\Collection;
use Illuminate\Support\Stringable;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\NodeAbstract;
use PHPStan\Analyser\Scope;
use PHPStan\Node\AnonymousClassNode;
use PHPStan\Rules\Rule;
use PHPStan\Type\ObjectType;
use ReflectionClass;
use ReflectionException;
use ReflectionIntersectionType;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionUnionType;

/**
* @implements \PHPStan\Rules\Rule<\PhpParser\Node\Expr\MethodCall>
*/
abstract class ScopeValidationMethods implements Rule
{
protected array $allowedRequestMethods = [];

abstract public function getNodeType(): string;

abstract public function processNode(Node $node, Scope $scope): array;

/**
* @phpstan-return ReflectionMethod[]
*/
protected function getClassMethods(Scope $scope): array
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the Scope object contains the PHP class that is being inspected. This method gathers all the methods that are present in that class

{
if (! $className = $scope->getClassReflection()?->getName()) {
return [];
}

$type = new ObjectType(className: $className, classReflection: $scope->getClassReflection());
/** @var ReflectionMethod[] $methods */
$methods = array_map(static function (string $className): array {
try {
return (new ReflectionClass($className))->getMethods();
} catch (ReflectionException) {
// @phpstan-ignore phpstanApi.constructor
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoring because PHPStan complains that it is not backwards compatible, but otherwise we'd get a "not found" for anonymous classes.

return (new AnonymousClassNode($className))->getMethods();
}
}, $type->getObjectClassNames());

return $methods;
}

protected function getMethodParameterClassnames(array $method): Collection
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method receives a list of methods, for which it will get the parameters and return the type.

{
return collect($method)
->map(fn (ReflectionMethod $method) => $method->getParameters())
->map(fn (array $parameter) => array_map(
static fn (ReflectionParameter $parameter) => $parameter->getType(), $parameter)
)
->flatten()
->filter()
->filter(fn ($type) => method_exists($type, 'getName'))
->map(fn (ReflectionNamedType|ReflectionUnionType|ReflectionIntersectionType $type) => $type->getName());
}

protected function nodeName(CallLike|Expr|NodeAbstract $var): string
{
if (! property_exists($var, 'name')) {
return '';
}

if ($var->name instanceof Stringable) {
return $var->name->toString();
}

if ($var->name instanceof Identifier) {
if (method_exists($var->name, 'toString')) {
return $var->name->toString();
}

return $var->name->name;
}

if ($var->name instanceof Variable) {
return $var->name->name;
}

if ($var->name instanceof Node\Expr\BinaryOp\Concat) {
if (method_exists($var->name, 'toString')) {
return $var->name->toString();
}

return '';
}

if ($var->name instanceof PropertyFetch) {
if (method_exists($var->name, 'toString')) {
return $var->name->toString();
}

return $var->name->name->toString();
}

if ($var instanceof PropertyFetch) {
return $var->name->toString();
}

if (method_exists($var->name, 'toString')) {
return $var->name->toString();
}

return $var->name ?? '';
}

protected function hasValidNamespace(?string $namespace): bool
{
if (! $namespace) {
return false;
}

return str_starts_with($namespace, 'App\\Http\\Request');
}

protected function usesValidMethod(string $varName, string $methodName): bool
{
if (! in_array($varName, ['request', 'safe', 'validated'], true)) {
return true;
}

if ($varName === 'request' && ($methodName === 'validated' || $methodName === 'validate')) {
return true;
}

if ($varName === 'request' && $methodName === 'safe') {
return true;
}

if ($varName === 'safe') {
return $this->isValidateMethod($methodName);
}

return ! $this->isValidateMethod($methodName);
}

protected function isValidateMethod(string $methodName): bool
{
return in_array($methodName, $this->allowedRequestMethods, strict: true);
}
}
61 changes: 61 additions & 0 deletions tests/Rules/Validation/ScopeRequestValidateMethodTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php declare(strict_types=1);

namespace Rules\Validation;

use Hihaho\PhpstanRules\Rules\Validation\ScopeRequestValidateMethods;
use PHPStan\Analyser\Error;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\Test;

/**
* @extends RuleTestCase<ScopeRequestValidateMethods>
*/
final class ScopeRequestValidateMethodTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new ScopeRequestValidateMethods([
'collect',
'all',
'only',
'except',
'input',
'get',
'keys',
'string',
'str',
'integer',
'float',
'boolean',
]);
}

#[Test]
public function illuminate_http_request_does_not_use_unvalidated_methods_outside_app_http_requests_namespace(): void
{
/** @var Error[] $errors */
$errors = $this->gatherAnalyserErrors([__DIR__ . '/../../stubs/App/Http/Controllers/PeopleControllerStub.php']);
$validErrors = array_filter(
$errors,
static fn (Error $error): bool => $error->getIdentifier() === 'hihaho.request.unsafeRequestData'
);

self::assertCount(6, $validErrors);

foreach ($validErrors as $key => $error) {
self::assertStringContainsString('Usage of unvalidated request data is not allowed outside of App\\Http\\Requests', $error->getMessage());
self::assertFalse($error->canBeIgnored());
self::assertStringContainsString('Use $request->safe() / $request->validated() to use request data', $error->getTip());
self::assertStringContainsString('Current checking: variable request, method', $error->getTip());
match($key) {
0 => self::assertSame(13, $error->getNodeLine()),
1 => self::assertSame(14, $error->getNodeLine()),
2 => self::assertSame(15, $error->getNodeLine()),
3 => self::assertSame(16, $error->getNodeLine()),
4 => self::assertSame(18, $error->getNodeLine()),
5 => self::assertSame(21, $error->getNodeLine()),
};
}
}
}
Loading