Skip to content

Commit

Permalink
Can exclude some attributes, calls, namespaces
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.
  • Loading branch information
spaze committed May 26, 2023
1 parent 245c927 commit 114ded7
Show file tree
Hide file tree
Showing 16 changed files with 118 additions and 17 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
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
10 changes: 9 additions & 1 deletion src/Identifier/Identifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,24 @@ class Identifier
/**
* @param string $pattern
* @param string $value
* @param list<string> $excludes
* @return bool
*/
public function matches(string $pattern, string $value): 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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/RuleErrors/DisallowedAttributeRuleErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function get(Attribute $attribute, Scope $scope, array $disallowedAttribu
{
foreach ($disallowedAttributes as $disallowedAttribute) {
$attributeName = $attribute->name->toString();
if (!$this->identifier->matches($disallowedAttribute->getAttribute(), $attributeName)) {
if (!$this->identifier->matches($disallowedAttribute->getAttribute(), $attributeName, $disallowedAttribute->getExcludes())) {
continue;
}
if ($this->allowed->isAllowed($scope, $attribute->args, $disallowedAttribute)) {
Expand Down
2 changes: 1 addition & 1 deletion src/RuleErrors/DisallowedCallsRuleErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public function __construct(Allowed $allowed, Identifier $identifier)
public function get(?CallLike $node, Scope $scope, string $name, ?string $displayName, array $disallowedCalls, ?string $message = null): array
{
foreach ($disallowedCalls as $disallowedCall) {
$callMatches = $this->identifier->matches($disallowedCall->getCall(), $name);
$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
2 changes: 1 addition & 1 deletion src/RuleErrors/DisallowedNamespaceRuleErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function getDisallowedMessage(string $namespace, string $description, Sco
continue;
}

if (!$this->identifier->matches($disallowedNamespace->getNamespace(), $namespace)) {
if (!$this->identifier->matches($disallowedNamespace->getNamespace(), $namespace, $disallowedNamespace->getExcludes())) {
continue;
}

Expand Down
3 changes: 3 additions & 0 deletions tests/Calls/FunctionCallsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ protected function getRule(): Rule
],
[
'function' => 'shell_*',
'exclude' => [
'shell_b*()',
],
'allowIn' => [
'../src/disallowed-allow/*.php',
'../src/*-allow/*.*',
Expand Down
32 changes: 22 additions & 10 deletions tests/Identifier/IdentifierTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,40 +22,52 @@ protected function setUp(): void
/**
* @param string $pattern
* @param string $value
* @param list<string>|null $excludes
* @return void
* @dataProvider matchesProvider
*/
public function testMatches(string $pattern, string $value): void
public function testMatches(string $pattern, string $value, ?array $excludes): void
{
$this->assertTrue($this->identifier->matches($pattern, $value));
$this->assertTrue($this->identifier->matches($pattern, $value, $excludes));
}


/**
* @param string $pattern
* @param string $value
* @param list<string>|null $excludes
* @return void
* @dataProvider doesNotMatchProvider
*/
public function testDoesNotMatch(string $pattern, string $value): void
public function testDoesNotMatch(string $pattern, string $value, ?array $excludes): void
{
$this->assertFalse($this->identifier->matches($pattern, $value));
$this->assertFalse($this->identifier->matches($pattern, $value, $excludes));
}


public static function matchesProvider(): Generator
{
yield ['foo', 'foo'];
yield ['foo', 'Foo'];
yield ['foo\\bar', 'foo\\bar'];
yield ['foo\\*', 'Foo\\Bar'];
yield ['foo', 'foo', []];
yield ['foo', 'Foo', []];
yield ['foo\\*', 'Foo\\Bar', []];
yield ['foo\\bar', 'foo\\bar', []];
yield ['foo\\bar', 'Foo\\Bar', []];
yield ['foo\\bar', 'foo\\bar', ['bar*']];
yield ['foo\\bar', 'foo\\bar', ['n*pe', 'bar\\*']];
}


public static function doesNotMatchProvider(): Generator
{
yield ['foo', 'bar'];
yield ['foo\\*', 'Bar\\Foo'];
yield ['foo', 'bar', []];
yield ['foo', 'foo', ['foo']];
yield ['foo', 'Foo', ['foo']];
yield ['foo', 'Foo', ['fOO']];
yield ['foo', 'Foo', ['f*']];
yield ['foo', 'Foo', ['F*']];
yield ['foo\\*', 'Bar\\Foo', []];
yield ['foo\\bar', 'foo\\bar', ['foo*']];
yield ['foo\\bar', 'foo\\bar', ['n*pe', 'foo\\*']];
}

}
3 changes: 3 additions & 0 deletions tests/src/disallowed-allow/functionCalls.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,6 @@
\Foo\Bar\Waldo\config('foo', ['key' => 'allow']);
// allowed by path
\Foo\Bar\Waldo\config('foo', ['key' => 'disallow']);

// allowed by path
shell_by();
3 changes: 3 additions & 0 deletions tests/src/disallowed/functionCalls.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,6 @@
\Foo\Bar\Waldo\config('foo', ['key' => 'allow']);
// disallowed array param, unsupported type in config
\Foo\Bar\Waldo\config('foo', ['key' => 'disallow']);

// would match shell_* but is excluded
shell_by();

0 comments on commit 114ded7

Please sign in to comment.