Skip to content

Commit 674f40c

Browse files
authored
Merge pull request #70 from yoeunes/dev
Introduces advanced regex analysis, tolerant parsing, and PSR caching
2 parents 1f4bf94 + 63033c8 commit 674f40c

29 files changed

+1474
-213
lines changed

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,18 @@ use RegexParser\Regex;
173173
$analysis = Regex::create()->analyzeReDoS('/(a+)+b/');
174174
echo $analysis->severity->value; // critical/high/...
175175
echo $analysis->score; // 0-10
176+
$isOkForRoutes = !$analysis->exceedsThreshold(\RegexParser\ReDoS\ReDoSSeverity::HIGH);
177+
$isOkForUserInput = !$analysis->exceedsThreshold(\RegexParser\ReDoS\ReDoSSeverity::LOW);
178+
179+
// IDE-friendly tolerant parsing: returns partial AST + errors list instead of throwing.
180+
$result = Regex::create()->parseTolerant('/(a+/');
181+
var_dump($result->hasErrors()); // true
182+
echo $result->errors[0]->getMessage(); // e.g. "Unclosed group"
176183
```
177184

178-
Severity levels: SAFE, LOW, MEDIUM, HIGH, CRITICAL (2^n worst cases).
185+
Severity levels: SAFE, LOW, MEDIUM, UNKNOWN, HIGH, CRITICAL (2^n worst cases; UNKNOWN means analysis could not complete safely).
186+
187+
Limitations: heuristic/static only; quantified alternations with complex character classes may still warn conservatively, and deeply recursive backreference/subroutine patterns can evade detection. Treat `UNKNOWN` as a signal to fail closed.
179188

180189
---
181190

@@ -208,6 +217,22 @@ $regex = Regex::create(['cache' => __DIR__ . '/var/cache/regex']);
208217
$ast = $regex->parse('/[A-Z][a-z]+/');
209218
```
210219

220+
Or plug your app cache (PSR-6/16) for shared keys:
221+
222+
```php
223+
use RegexParser\Regex;
224+
use RegexParser\Cache\PsrCacheAdapter;
225+
use RegexParser\Cache\PsrSimpleCacheAdapter;
226+
227+
// PSR-6 (CacheItemPoolInterface)
228+
$cache = new PsrCacheAdapter($yourPool, prefix: 'route_login_');
229+
$regex = Regex::create(['cache' => $cache]);
230+
231+
// PSR-16 (SimpleCache)
232+
$cache = new PsrSimpleCacheAdapter($yourSimpleCache, prefix: 'constraint_user_email_');
233+
$regex = Regex::create(['cache' => $cache]);
234+
```
235+
211236
Pass a writable directory string to `Regex::create(['cache' => '/path'])` or a custom `CacheInterface` implementation. Use `null` (default) to disable.
212237

213238
---

bin/regex-analyze

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
declare(strict_types=1);
5+
6+
/*
7+
* This file is part of the RegexParser package.
8+
*
9+
* (c) Younes ENNAJI <[email protected]>
10+
*
11+
* For the full copyright and license information, please view the LICENSE
12+
* file that was distributed with this source code.
13+
*/
14+
15+
require __DIR__.'/../vendor/autoload.php';
16+
17+
use RegexParser\Exception\LexerException;
18+
use RegexParser\Exception\ParserException;
19+
use RegexParser\Regex;
20+
21+
if ($argc < 2) {
22+
fwrite(STDERR, "Usage: regex-analyze '/pattern/flags'\n");
23+
exit(1);
24+
}
25+
26+
$pattern = $argv[1];
27+
$regex = Regex::create();
28+
29+
try {
30+
$ast = $regex->parse($pattern);
31+
$validation = $regex->validate($pattern);
32+
$analysis = $regex->analyzeReDoS($pattern);
33+
$explain = $regex->explain($pattern);
34+
35+
echo "Pattern: {$pattern}\n";
36+
echo "Parse: OK\n";
37+
echo "Validation: ".($validation->isValid ? 'OK' : 'INVALID')."\n";
38+
if (!$validation->isValid && null !== $validation->error) {
39+
echo "Error: {$validation->error}\n";
40+
}
41+
echo "ReDoS severity: {$analysis->severity->value}\n";
42+
echo "ReDoS score: {$analysis->score}\n";
43+
if ($analysis->error) {
44+
echo "ReDoS error: {$analysis->error}\n";
45+
}
46+
echo "\nExplanation:\n";
47+
echo $explain."\n";
48+
} catch (LexerException|ParserException $e) {
49+
fwrite(STDERR, "Error: {$e->getMessage()}\n");
50+
exit(1);
51+
}

composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
"symfony/config": "^8.0",
2424
"symfony/http-foundation": "^8.0",
2525
"symfony/routing": "^8.0",
26-
"symfony/validator": "^8.0"
26+
"symfony/validator": "^8.0",
27+
"psr/cache": "^3.0",
28+
"psr/simple-cache": "^3.0"
2729
},
2830
"autoload": {
2931
"psr-4": {
@@ -48,6 +50,8 @@
4850
}
4951
},
5052
"suggest": {
53+
"psr/cache": "To share AST cache via PSR-6 pools.",
54+
"psr/simple-cache": "To share AST cache via PSR-16 caches.",
5155
"phpstan/phpstan": "To run static analysis and detect invalid regex patterns.",
5256
"phpstan/extension-installer": "To automatically enable the PHPStan rule for regex validation.",
5357
"rector/rector": "To automatically refactor and optimize regex patterns."

phpstan.dist.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ parameters:
1010

1111
treatPhpDocTypesAsCertain: false
1212

13+
regexParser:
14+
redosThreshold: critical
15+
1316
paths:
1417
- bin
1518
- config
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the RegexParser package.
7+
*
8+
* (c) Younes ENNAJI <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace RegexParser\Rector\Rule\PHPUnit;
15+
16+
use PhpParser\Node;
17+
use PhpParser\Node\Expr\MethodCall;
18+
use PhpParser\Node\Expr\StaticCall;
19+
use PHPUnit\Framework\Assert;
20+
use Rector\PHPUnit\CodeQuality\NodeAnalyser\AssertMethodAnalyzer;
21+
use Rector\Rector\AbstractRector;
22+
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
23+
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
24+
25+
/**
26+
* Converts PHPUnit assertions from $this->assert*() and static::assert*() to explicit Assert::assert*() static calls.
27+
*/
28+
class PreferStaticPHPUnitAssertRector extends AbstractRector
29+
{
30+
public function __construct(private AssertMethodAnalyzer $assertMethodAnalyzer) {}
31+
32+
public function getRuleDefinition(): RuleDefinition
33+
{
34+
return new RuleDefinition(
35+
'Changes PHPUnit assertion calls from $this->assert*() or static::assert*() to explicit Assert::assert*() static calls.',
36+
[
37+
new CodeSample(
38+
<<<'CODE_SAMPLE'
39+
use PHPUnit\Framework\TestCase;
40+
41+
final class SomeTest extends TestCase {
42+
public function testSomething() {
43+
$this->assertEquals(1, 2);
44+
static::assertSame('foo', 'bar');
45+
}
46+
}
47+
CODE_SAMPLE,
48+
<<<'CODE_SAMPLE'
49+
use PHPUnit\Framework\TestCase;
50+
use PHPUnit\Framework\Assert;
51+
52+
final class SomeTest extends TestCase {
53+
public function testSomething() {
54+
Assert::assertEquals(1, 2);
55+
Assert::assertSame('foo', 'bar');
56+
}
57+
}
58+
CODE_SAMPLE
59+
),
60+
],
61+
);
62+
}
63+
64+
public function getNodeTypes(): array
65+
{
66+
return [MethodCall::class, StaticCall::class];
67+
}
68+
69+
/**
70+
* @param MethodCall|StaticCall $node
71+
*/
72+
public function refactor(Node $node): ?Node
73+
{
74+
if ($node->isFirstClassCallable()) {
75+
return null;
76+
}
77+
78+
if ($node instanceof MethodCall && !$this->assertMethodAnalyzer->detectTestCaseCallForStatic($node)) {
79+
return null;
80+
}
81+
82+
if ($node instanceof StaticCall && !$this->assertMethodAnalyzer->detectTestCaseCall($node)) {
83+
return null;
84+
}
85+
86+
$methodName = $this->getName($node->name);
87+
if (null === $methodName || !str_starts_with($methodName, 'assert')) {
88+
return null;
89+
}
90+
91+
return $this->nodeFactory->createStaticCall(
92+
Assert::class,
93+
$methodName,
94+
$node->getArgs(),
95+
);
96+
}
97+
}

src/Bridge/PHPStan/PregValidationRule.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,10 @@ private function exceedsThreshold(ReDoSSeverity $severity): bool
216216
$currentLevel = match ($severity) {
217217
ReDoSSeverity::SAFE => 0,
218218
ReDoSSeverity::LOW => 1,
219-
ReDoSSeverity::MEDIUM => 2,
220-
ReDoSSeverity::HIGH => 3,
221-
ReDoSSeverity::CRITICAL => 4,
219+
ReDoSSeverity::UNKNOWN => 2,
220+
ReDoSSeverity::MEDIUM => 3,
221+
ReDoSSeverity::HIGH => 4,
222+
ReDoSSeverity::CRITICAL => 5,
222223
};
223224

224225
$thresholdLevel = match ($this->redosThreshold) {

src/Cache/FilesystemCache.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public function __construct(string $directory, private string $extension = '.php
2222
$this->directory = rtrim($directory, '\\/');
2323
}
2424

25+
#[\Override]
2526
public function generateKey(string $regex): string
2627
{
2728
$hash = hash('sha256', $regex);
@@ -37,6 +38,7 @@ public function generateKey(string $regex): string
3738
);
3839
}
3940

41+
#[\Override]
4042
public function write(string $key, string $content): void
4143
{
4244
$directory = \dirname($key);
@@ -70,6 +72,7 @@ public function write(string $key, string $content): void
7072
}
7173
}
7274

75+
#[\Override]
7376
public function load(string $key): mixed
7477
{
7578
if (!is_file($key)) {
@@ -84,11 +87,13 @@ public function load(string $key): mixed
8487
}
8588
}
8689

90+
#[\Override]
8791
public function getTimestamp(string $key): int
8892
{
8993
return is_file($key) ? (int) filemtime($key) : 0;
9094
}
9195

96+
#[\Override]
9297
public function clear(?string $regex = null): void
9398
{
9499
if (null !== $regex) {

src/Cache/NullCache.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,29 @@
1313

1414
namespace RegexParser\Cache;
1515

16-
class NullCache implements RemovableCacheInterface
16+
readonly class NullCache implements RemovableCacheInterface
1717
{
18+
#[\Override]
1819
public function generateKey(string $regex): string
1920
{
2021
return hash('sha256', $regex);
2122
}
2223

24+
#[\Override]
2325
public function write(string $key, string $content): void {}
2426

27+
#[\Override]
2528
public function load(string $key): mixed
2629
{
2730
return null;
2831
}
2932

33+
#[\Override]
3034
public function getTimestamp(string $key): int
3135
{
3236
return 0;
3337
}
3438

39+
#[\Override]
3540
public function clear(?string $regex = null): void {}
3641
}

src/Cache/PsrCacheAdapter.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the RegexParser package.
7+
*
8+
* (c) Younes ENNAJI <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace RegexParser\Cache;
15+
16+
use Psr\Cache\CacheItemPoolInterface;
17+
18+
/**
19+
* PSR-6 bridge for AST caching.
20+
*
21+
* Uses the configured pool to store serialized AST payloads.
22+
*/
23+
final readonly class PsrCacheAdapter implements RemovableCacheInterface
24+
{
25+
public function __construct(
26+
private CacheItemPoolInterface $pool,
27+
private string $prefix = 'regex_',
28+
private ?\Closure $keyFactory = null
29+
) {}
30+
31+
public function generateKey(string $regex): string
32+
{
33+
if (null !== $this->keyFactory) {
34+
$custom = ($this->keyFactory)($regex);
35+
36+
return $this->prefix.(\is_string($custom) ? $custom : hash('sha256', serialize($custom)));
37+
}
38+
39+
return $this->prefix.hash('sha256', $regex);
40+
}
41+
42+
public function write(string $key, string $content): void
43+
{
44+
$item = $this->pool->getItem($key);
45+
$item->set($content);
46+
$this->pool->save($item);
47+
}
48+
49+
public function load(string $key): mixed
50+
{
51+
$item = $this->pool->getItem($key);
52+
53+
return $item->isHit() ? $item->get() : null;
54+
}
55+
56+
public function getTimestamp(string $key): int
57+
{
58+
// PSR-6 does not expose timestamps; return 0 (unknown).
59+
return 0;
60+
}
61+
62+
public function clear(?string $regex = null): void
63+
{
64+
if (null !== $regex) {
65+
$this->pool->deleteItem($this->generateKey($regex));
66+
67+
return;
68+
}
69+
70+
$this->pool->clear();
71+
}
72+
}

0 commit comments

Comments
 (0)