Skip to content

Commit

Permalink
Can exclude some attributes, calls, namespaces (#197)
Browse files Browse the repository at this point in the history
Handy when you disallow items with wildcards but there's this one thing you'd like to leave out.

```neon
parameters:
    disallowedFunctionCalls:
        -
            function: 'pcntl_*()'
            exclude:
                - 'pcntl_foo*()'
```
  • Loading branch information
spaze authored May 26, 2023
2 parents ed205b4 + 114ded7 commit b49a7fd
Show file tree
Hide file tree
Showing 42 changed files with 264 additions and 63 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ parameters:
```
The wildcard makes most sense when used as the rightmost character of the function or method name, optionally followed by `()`, but you can use it anywhere for example to disallow all functions that end with `y`: `function: '*y()'`. The matching is powered by [`fnmatch`](https://www.php.net/function.fnmatch) so you can use even multiple wildcards if you wish because w\*y n\*t.

If there's this one function, method, namespace, attribute (or multiple of them) that you'd like to exclude from the set, you can do that with `exclude`:
```neon
parameters:
disallowedFunctionCalls:
-
function: 'pcntl_*()'
exclude:
- 'pcntl_foobar()'
```
This config would disallow all `pcntl` functions except (an imaginary) `pcntl_foobar()`.
Please note `exclude` also accepts [`fnmatch`](https://www.php.net/function.fnmatch) patterns so please be careful to not create a contradicting config.

You can treat some language constructs as functions and disallow it in `disallowedFunctionCalls`. Currently detected language constructs are:
- `die()`
- `echo()`
Expand Down
1 change: 1 addition & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ services:
- Spaze\PHPStan\Rules\Disallowed\DisallowedNamespaceFactory
- Spaze\PHPStan\Rules\Disallowed\DisallowedSuperglobalFactory
- Spaze\PHPStan\Rules\Disallowed\Formatter\Formatter
- Spaze\PHPStan\Rules\Disallowed\Identifier\Identifier
- Spaze\PHPStan\Rules\Disallowed\Normalizer\Normalizer
- Spaze\PHPStan\Rules\Disallowed\RuleErrors\DisallowedAttributeRuleErrors
- Spaze\PHPStan\Rules\Disallowed\RuleErrors\DisallowedConstantRuleErrors
Expand Down
4 changes: 2 additions & 2 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ parameters:
CallParamAnyValueConfig: 'array<int|string, int|array{position:int, value?:int|bool|string, name?:string}>'
CallParamFlagAnyValueConfig: 'array<int|string, int|array{position:int, value?:int, name?:string}>'
AllowDirectives: 'allowIn?:string[], allowExceptIn?:string[], disallowIn?:string[], allowInFunctions?:string[], allowInMethods?:string[], allowExceptInFunctions?:string[], allowExceptInMethods?:string[], disallowInFunctions?:string[], disallowInMethods?:string[], allowParamsInAllowed?:CallParamConfig, allowParamsInAllowedAnyValue?:CallParamAnyValueConfig, allowParamFlagsInAllowed?:CallParamFlagAnyValueConfig, allowParamsAnywhere?:CallParamConfig, allowParamsAnywhereAnyValue?:CallParamAnyValueConfig, allowParamFlagsAnywhere?:CallParamFlagAnyValueConfig, allowExceptParamsInAllowed?:CallParamConfig, allowExceptParamFlagsInAllowed?:CallParamFlagAnyValueConfig, disallowParamFlagsInAllowed?:CallParamFlagAnyValueConfig, disallowParamsInAllowed?:CallParamConfig, allowExceptParams?:CallParamConfig, disallowParams?:CallParamConfig, allowExceptParamFlags?:CallParamFlagAnyValueConfig, disallowParamFlags?:CallParamFlagAnyValueConfig, allowExceptCaseInsensitiveParams?:CallParamConfig, disallowCaseInsensitiveParams?:CallParamConfig'
ForbiddenCallsConfig: 'array<array{function?:string|list<string>, method?:string|list<string>, message?:string, %typeAliases.AllowDirectives%, errorIdentifier?:string, errorTip?:string}>'
DisallowedAttributesConfig: 'array<array{attribute:string|list<string>, message?:string, %typeAliases.AllowDirectives%, errorIdentifier?:string, errorTip?:string}>'
ForbiddenCallsConfig: 'array<array{function?:string|list<string>, method?:string|list<string>, exclude?:list<string>, message?:string, %typeAliases.AllowDirectives%, errorIdentifier?:string, errorTip?:string}>'
DisallowedAttributesConfig: 'array<array{attribute:string|list<string>, exclude?:list<string>, message?:string, %typeAliases.AllowDirectives%, errorIdentifier?:string, errorTip?:string}>'
AllowDirectivesConfig: 'array{%typeAliases.AllowDirectives%}'

includes:
Expand Down
15 changes: 15 additions & 0 deletions src/DisallowedAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class DisallowedAttribute implements DisallowedWithParams
/** @var string */
private $attribute;

/** @var list<string> */
private $excludes;

/** @var string|null */
private $message;

Expand All @@ -26,19 +29,22 @@ class DisallowedAttribute implements DisallowedWithParams

/**
* @param string $attribute
* @param list<string> $excludes
* @param string|null $message
* @param AllowedConfig $allowedConfig
* @param string|null $errorIdentifier
* @param string|null $errorTip
*/
public function __construct(
string $attribute,
array $excludes,
?string $message,
AllowedConfig $allowedConfig,
?string $errorIdentifier,
?string $errorTip
) {
$this->attribute = $attribute;
$this->excludes = $excludes;
$this->message = $message;
$this->allowedConfig = $allowedConfig;
$this->errorIdentifier = $errorIdentifier;
Expand All @@ -52,6 +58,15 @@ public function getAttribute(): string
}


/**
* @return list<string>
*/
public function getExcludes(): array
{
return $this->excludes;
}


public function getMessage(): string
{
return $this->message ?? 'because reasons';
Expand Down
5 changes: 5 additions & 0 deletions src/DisallowedAttributeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ public function createFromConfig(array $config): array
$disallowedAttributes = [];
foreach ($config as $disallowed) {
$attributes = $disallowed['attribute'];
$excludes = [];
foreach ($disallowed['exclude'] ?? [] as $exclude) {
$excludes[] = $this->normalizer->normalizeNamespace($exclude);
}
foreach ((array)$attributes as $attribute) {
$disallowedAttribute = new DisallowedAttribute(
$this->normalizer->normalizeNamespace($attribute),
$excludes,
$disallowed['message'] ?? null,
$this->allowed->getConfig($disallowed),
$disallowed['errorIdentifier'] ?? null,
Expand Down
15 changes: 15 additions & 0 deletions src/DisallowedCall.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class DisallowedCall implements DisallowedWithParams
/** @var string */
private $call;

/** @var list<string> */
private $excludes;

/** @var string|null */
private $message;

Expand All @@ -26,19 +29,22 @@ class DisallowedCall implements DisallowedWithParams

/**
* @param string $call
* @param list<string> $excludes
* @param string|null $message
* @param AllowedConfig $allowedConfig
* @param string|null $errorIdentifier
* @param string|null $errorTip
*/
public function __construct(
string $call,
array $excludes,
?string $message,
AllowedConfig $allowedConfig,
?string $errorIdentifier,
?string $errorTip
) {
$this->call = $call;
$this->excludes = $excludes;
$this->message = $message;
$this->allowedConfig = $allowedConfig;
$this->errorIdentifier = $errorIdentifier;
Expand All @@ -52,6 +58,15 @@ public function getCall(): string
}


/**
* @return list<string>
*/
public function getExcludes(): array
{
return $this->excludes;
}


public function getMessage(): string
{
return $this->message ?? 'because reasons';
Expand Down
5 changes: 5 additions & 0 deletions src/DisallowedCallFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,16 @@ public function createFromConfig(array $config): array
if (!$calls) {
throw new ShouldNotHappenException("Either 'method' or 'function' must be set in configuration items");
}
$excludes = [];
foreach ($disallowed['exclude'] ?? [] as $exclude) {
$excludes[] = $this->normalizer->normalizeCall($exclude);
}
$calls = (array)$calls;
try {
foreach ($calls as $call) {
$disallowedCall = new DisallowedCall(
$this->normalizer->normalizeCall($call),
$excludes,
$disallowed['message'] ?? null,
$this->allowed->getConfig($disallowed),
$disallowed['errorIdentifier'] ?? null,
Expand Down
15 changes: 15 additions & 0 deletions src/DisallowedNamespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class DisallowedNamespace implements Disallowed
/** @var string */
private $namespace;

/** @var list<string> */
private $excludes;

/** @var string|null */
private $message;

Expand All @@ -29,6 +32,7 @@ class DisallowedNamespace implements Disallowed

/**
* @param string $namespace
* @param list<string> $excludes
* @param string|null $message
* @param string[] $allowIn
* @param string[] $allowExceptIn
Expand All @@ -37,13 +41,15 @@ class DisallowedNamespace implements Disallowed
*/
public function __construct(
string $namespace,
array $excludes,
?string $message,
array $allowIn,
array $allowExceptIn,
?string $errorIdentifier,
?string $errorTip
) {
$this->namespace = $namespace;
$this->excludes = $excludes;
$this->message = $message;
$this->allowIn = $allowIn;
$this->allowExceptIn = $allowExceptIn;
Expand All @@ -58,6 +64,15 @@ public function getNamespace(): string
}


/**
* @return list<string>
*/
public function getExcludes(): array
{
return $this->excludes;
}


public function getMessage(): string
{
return $this->message ?? 'because reasons';
Expand Down
7 changes: 6 additions & 1 deletion src/DisallowedNamespaceFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function __construct(Normalizer $normalizer)


/**
* @param array<array{namespace?:string, class?:string, message?:string, allowIn?:string[], allowExceptIn?:string[], disallowIn?:string[], errorIdentifier?:string, errorTip?:string}> $config
* @param array<array{namespace?:string, class?:string, exclude?:list<string>, message?:string, allowIn?:string[], allowExceptIn?:string[], disallowIn?:string[], errorIdentifier?:string, errorTip?:string}> $config
* @return DisallowedNamespace[]
*/
public function createFromConfig(array $config): array
Expand All @@ -32,9 +32,14 @@ public function createFromConfig(array $config): array
if (!$namespaces) {
throw new ShouldNotHappenException("Either 'namespace' or 'class' must be set in configuration items");
}
$excludes = [];
foreach ($disallowed['exclude'] ?? [] as $exclude) {
$excludes[] = $this->normalizer->normalizeNamespace($exclude);
}
foreach ((array)$namespaces as $namespace) {
$disallowedNamespace = new DisallowedNamespace(
$this->normalizer->normalizeNamespace($namespace),
$excludes,
$disallowed['message'] ?? null,
$disallowed['allowIn'] ?? [],
$disallowed['allowExceptIn'] ?? $disallowed['disallowIn'] ?? [],
Expand Down
33 changes: 33 additions & 0 deletions src/Identifier/Identifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
declare(strict_types = 1);

namespace Spaze\PHPStan\Rules\Disallowed\Identifier;

class Identifier
{

/**
* @param string $pattern
* @param string $value
* @param list<string> $excludes
* @return bool
*/
public function matches(string $pattern, string $value, array $excludes): bool
{
$matches = false;
if ($pattern === $value) {
$matches = true;
} elseif (fnmatch($pattern, $value, FNM_NOESCAPE | FNM_CASEFOLD)) {
$matches = true;
}
if ($matches) {
foreach ($excludes as $exclude) {
if (fnmatch($exclude, $value, FNM_NOESCAPE | FNM_CASEFOLD)) {
return false;
}
}
}
return $matches;
}

}
23 changes: 7 additions & 16 deletions src/RuleErrors/DisallowedAttributeRuleErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,22 @@
use PHPStan\Rules\RuleErrorBuilder;
use Spaze\PHPStan\Rules\Disallowed\Allowed\Allowed;
use Spaze\PHPStan\Rules\Disallowed\DisallowedAttribute;
use Spaze\PHPStan\Rules\Disallowed\Identifier\Identifier;

class DisallowedAttributeRuleErrors
{

/** @var Allowed */
private $allowed;

/** @var Identifier */
private $identifier;

public function __construct(Allowed $allowed)

public function __construct(Allowed $allowed, Identifier $identifier)
{
$this->allowed = $allowed;
$this->identifier = $identifier;
}


Expand All @@ -33,7 +38,7 @@ public function get(Attribute $attribute, Scope $scope, array $disallowedAttribu
{
foreach ($disallowedAttributes as $disallowedAttribute) {
$attributeName = $attribute->name->toString();
if (!$this->matchesAttribute($disallowedAttribute->getAttribute(), $attributeName)) {
if (!$this->identifier->matches($disallowedAttribute->getAttribute(), $attributeName, $disallowedAttribute->getExcludes())) {
continue;
}
if ($this->allowed->isAllowed($scope, $attribute->args, $disallowedAttribute)) {
Expand All @@ -60,18 +65,4 @@ public function get(Attribute $attribute, Scope $scope, array $disallowedAttribu
return [];
}


private function matchesAttribute(string $pattern, string $value): bool
{
if ($pattern === $value) {
return true;
}

if (fnmatch($pattern, $value, FNM_NOESCAPE | FNM_CASEFOLD)) {
return true;
}

return false;
}

}
9 changes: 7 additions & 2 deletions src/RuleErrors/DisallowedCallsRuleErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,22 @@
use PHPStan\ShouldNotHappenException;
use Spaze\PHPStan\Rules\Disallowed\Allowed\Allowed;
use Spaze\PHPStan\Rules\Disallowed\DisallowedCall;
use Spaze\PHPStan\Rules\Disallowed\Identifier\Identifier;

class DisallowedCallsRuleErrors
{

/** @var Allowed */
private $allowed;

/** @var Identifier */
private $identifier;

public function __construct(Allowed $allowed)

public function __construct(Allowed $allowed, Identifier $identifier)
{
$this->allowed = $allowed;
$this->identifier = $identifier;
}


Expand All @@ -37,7 +42,7 @@ public function __construct(Allowed $allowed)
public function get(?CallLike $node, Scope $scope, string $name, ?string $displayName, array $disallowedCalls, ?string $message = null): array
{
foreach ($disallowedCalls as $disallowedCall) {
$callMatches = $name === $disallowedCall->getCall() || fnmatch($disallowedCall->getCall(), $name, FNM_NOESCAPE | FNM_CASEFOLD);
$callMatches = $this->identifier->matches($disallowedCall->getCall(), $name, $disallowedCall->getExcludes());
if ($callMatches && !$this->allowed->isAllowed($scope, isset($node) ? $node->getArgs() : null, $disallowedCall)) {
$errorBuilder = RuleErrorBuilder::message(sprintf(
$message ?? 'Calling %s is forbidden, %s%s',
Expand Down
Loading

0 comments on commit b49a7fd

Please sign in to comment.