Skip to content

Commit b84dfc5

Browse files
authored
Merge pull request #68 from yoeunes/dev
Refactors Symfony Bundle for Static Regex Analysis
2 parents f28d2f5 + 4e2111c commit b84dfc5

20 files changed

+883
-1487
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"phpunit/phpunit": "^12.4.5",
1818
"phpstan/phpstan": "^2.1.32",
1919
"phpstan/phpstan-phpunit": "^2.0.8",
20+
"symfony/console": "^7.3|^8.0",
2021
"symfony/http-kernel": "^7.3|^8.0",
2122
"symfony/dependency-injection": "^7.3|^8.0",
2223
"symfony/config": "^7.3|^8.0",

phpstan.dist.neon

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ parameters:
2424
- tests/Bridge/PHPStan/Fixtures/*
2525

2626
bootstrapFiles:
27-
# - tools/rector/vendor/autoload.php
28-
#- tools/phpbench/vendor/autoload.php
27+
- tools/phpstan/vendor/autoload.php
2928

3029
editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%'
3130

public/library-bundle.json

Lines changed: 79 additions & 78 deletions
Large diffs are not rendered by default.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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\Bridge\Symfony\Analyzer;
15+
16+
/**
17+
* @internal
18+
*/
19+
final readonly class RouteIssue
20+
{
21+
public function __construct(
22+
public string $message,
23+
public bool $isError,
24+
) {}
25+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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\Bridge\Symfony\Analyzer;
15+
16+
use RegexParser\Regex;
17+
use Symfony\Component\Routing\RouteCollection;
18+
19+
/**
20+
* Analyses Symfony route requirements and reports regex issues.
21+
*
22+
* @internal
23+
*/
24+
final readonly class RouteRequirementAnalyzer
25+
{
26+
private const array PATTERN_DELIMITERS = ['/', '#', '~', '%'];
27+
28+
public function __construct(
29+
private Regex $regex,
30+
private int $warningThreshold,
31+
private int $redosThreshold,
32+
) {}
33+
34+
/**
35+
* @return list<RouteIssue>
36+
*/
37+
public function analyze(RouteCollection $routes): array
38+
{
39+
$issues = [];
40+
41+
foreach ($routes as $name => $route) {
42+
foreach ($route->getRequirements() as $parameter => $requirement) {
43+
if (!\is_scalar($requirement)) {
44+
continue;
45+
}
46+
47+
$pattern = trim((string) $requirement);
48+
if ('' === $pattern) {
49+
continue;
50+
}
51+
52+
$normalizedPattern = $this->normalizePattern($pattern);
53+
$result = $this->regex->validate($normalizedPattern);
54+
55+
if (!$result->isValid) {
56+
$issues[] = new RouteIssue(
57+
\sprintf(
58+
'Route "%s" requirement "%s" is invalid: %s',
59+
(string) $name,
60+
$parameter,
61+
$result->error ?? 'unknown error',
62+
),
63+
true,
64+
);
65+
66+
continue;
67+
}
68+
69+
if ($result->complexityScore >= $this->redosThreshold) {
70+
$issues[] = new RouteIssue(
71+
\sprintf(
72+
'Route "%s" requirement "%s" may be vulnerable to ReDoS (score: %d).',
73+
(string) $name,
74+
$parameter,
75+
$result->complexityScore,
76+
),
77+
true,
78+
);
79+
80+
continue;
81+
}
82+
83+
if ($result->complexityScore >= $this->warningThreshold) {
84+
$issues[] = new RouteIssue(
85+
\sprintf(
86+
'Route "%s" requirement "%s" is complex (score: %d).',
87+
(string) $name,
88+
$parameter,
89+
$result->complexityScore,
90+
),
91+
false,
92+
);
93+
}
94+
}
95+
}
96+
97+
return $issues;
98+
}
99+
100+
private function normalizePattern(string $pattern): string
101+
{
102+
$firstChar = $pattern[0] ?? '';
103+
104+
if (\in_array($firstChar, self::PATTERN_DELIMITERS, true)) {
105+
return $pattern;
106+
}
107+
108+
$delimiter = '#';
109+
$body = str_replace($delimiter, '\\'.$delimiter, $pattern);
110+
111+
return $delimiter.'^'.$body.'$'.$delimiter;
112+
}
113+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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\Bridge\Symfony\CacheWarmer;
15+
16+
use Psr\Log\LoggerInterface;
17+
use Psr\Log\LogLevel;
18+
use RegexParser\Bridge\Symfony\Analyzer\RouteIssue;
19+
use RegexParser\Bridge\Symfony\Analyzer\RouteRequirementAnalyzer;
20+
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
21+
use Symfony\Component\Routing\RouterInterface;
22+
23+
/**
24+
* Surfaces regex warnings during Symfony cache warmup.
25+
*
26+
* @internal
27+
*/
28+
final readonly class RegexParserCacheWarmer implements CacheWarmerInterface
29+
{
30+
public function __construct(
31+
private RouteRequirementAnalyzer $analyzer,
32+
private ?RouterInterface $router = null,
33+
private ?LoggerInterface $logger = null,
34+
) {}
35+
36+
#[\Override]
37+
public function isOptional(): bool
38+
{
39+
return true;
40+
}
41+
42+
#[\Override]
43+
public function warmUp(string $cacheDir, ?string $buildDir = null): array
44+
{
45+
if (null === $this->router) {
46+
return [];
47+
}
48+
49+
$issues = $this->analyzer->analyze($this->router->getRouteCollection());
50+
51+
foreach ($issues as $issue) {
52+
$this->log($issue);
53+
}
54+
55+
return [];
56+
}
57+
58+
private function log(RouteIssue $issue): void
59+
{
60+
if (null !== $this->logger) {
61+
$this->logger->log(
62+
$issue->isError ? LogLevel::ERROR : LogLevel::WARNING,
63+
$issue->message,
64+
);
65+
66+
return;
67+
}
68+
69+
error_log($issue->message);
70+
}
71+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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\Bridge\Symfony\Command;
15+
16+
use RegexParser\Bridge\Symfony\Analyzer\RouteRequirementAnalyzer;
17+
use Symfony\Component\Console\Command\Command;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Symfony\Component\Console\Style\SymfonyStyle;
21+
use Symfony\Component\Routing\RouterInterface;
22+
23+
final class RegexParserValidateCommand extends Command
24+
{
25+
protected static $defaultName = 'regex-parser:check';
26+
27+
protected static $defaultDescription = 'Validates regex usage found in the Symfony application.';
28+
29+
public function __construct(
30+
private readonly RouteRequirementAnalyzer $analyzer,
31+
private readonly ?RouterInterface $router = null,
32+
) {
33+
parent::__construct();
34+
}
35+
36+
#[\Override]
37+
protected function execute(InputInterface $input, OutputInterface $output): int
38+
{
39+
$io = new SymfonyStyle($input, $output);
40+
41+
if (null === $this->router) {
42+
$io->warning('No router service was found; skipping regex checks.');
43+
44+
return Command::SUCCESS;
45+
}
46+
47+
$issues = $this->analyzer->analyze($this->router->getRouteCollection());
48+
49+
if ([] === $issues) {
50+
$io->success('No regex issues detected in route requirements.');
51+
52+
return Command::SUCCESS;
53+
}
54+
55+
$hasErrors = false;
56+
foreach ($issues as $issue) {
57+
$hasErrors = $hasErrors || $issue->isError;
58+
$io->writeln(\sprintf(
59+
'%s %s',
60+
$issue->isError ? '<error>[error]</error>' : '<comment>[warn]</comment>',
61+
$issue->message,
62+
));
63+
}
64+
65+
if (!$hasErrors) {
66+
$io->success('RegexParser found warnings only.');
67+
}
68+
69+
return $hasErrors ? Command::FAILURE : Command::SUCCESS;
70+
}
71+
}

src/Bridge/Symfony/DataCollector/CollectedRegex.php

Lines changed: 0 additions & 49 deletions
This file was deleted.

0 commit comments

Comments
 (0)