Skip to content

Commit 95889d9

Browse files
committed
Add simple taint analysis to detect variables containing user input
1 parent 9376b16 commit 95889d9

8 files changed

+172
-21
lines changed

Diff for: Security/Sniffs/UserInputDetector.php

+60-19
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
namespace NthRoot\PhpSecuritySniffs\Security\Sniffs;
66

77
use PHP_CodeSniffer\Files\File;
8+
use PHP_CodeSniffer\Util\Tokens;
89

910
use function array_slice;
1011
use function in_array;
12+
use function range;
1113

14+
use const T_EQUAL;
1215
use const T_OPEN_PARENTHESIS;
1316
use const T_VARIABLE;
1417

@@ -31,45 +34,83 @@ public function containsVariableInput(File $file, int $start): bool
3134

3235
public function containsUserInput(File $file, int $start): bool
3336
{
34-
$tokens = $this->getArgumentTokens($file, $start);
37+
$tokens = $file->getTokens();
3538

36-
foreach ($tokens as $token) {
37-
if ($this->isUserInput($token)) {
39+
if ($tokens[$start]['code'] === T_OPEN_PARENTHESIS) {
40+
$closingParenthesis = $tokens[$start]['parenthesis_closer'];
41+
42+
$tokenPositions = range($start + 1, $closingParenthesis - 1);
43+
} else {
44+
$tokenPositions = range($start, $file->findEndOfStatement($start) - 1);
45+
}
46+
47+
foreach ($tokenPositions as $token) {
48+
if ($this->isTainted($file, $token)) {
3849
return true;
3950
}
4051
}
4152

4253
return false;
4354
}
4455

45-
private function getArgumentTokens(File $file, int $start): array
56+
/**
57+
* Returns true if the token at the given position contains user input.
58+
*/
59+
public function isTainted(File $file, int $tokenPosition): bool
4660
{
4761
$tokens = $file->getTokens();
62+
$token = $tokens[$tokenPosition];
4863

49-
if ($tokens[$start]['code'] === T_OPEN_PARENTHESIS) {
50-
$closingParenthesis = $tokens[$start]['parenthesis_closer'];
51-
52-
return array_slice($tokens, $start + 1, $closingParenthesis - $start);
64+
if ($token['code'] === T_VARIABLE && in_array($token['content'], self::SUPERGLOBALS, true)) {
65+
return true;
5366
}
5467

55-
$endOfStatement = $file->findEndOfStatement($start);
68+
if ($token['code'] === T_VARIABLE) {
69+
$variableName = $token['content'];
5670

57-
return array_slice($tokens, $start, $endOfStatement - $start);
71+
$definition = $this->getVariableDefinition($file, $tokenPosition, $variableName);
72+
73+
if (!$definition) {
74+
// Cannot find the definition of the variable
75+
return false;
76+
}
77+
78+
$value = $file->findNext(T_VARIABLE, $definition + 1);
79+
80+
return $this->isTainted($file, $value);
81+
}
82+
83+
return false;
5884
}
5985

60-
/**
61-
* @param array{code: int, content: string} $token
62-
*/
63-
private function isUserInput(array $token): bool
86+
private function getVariableDefinition(File $file, int $tokenPosition, string $name): int|false
6487
{
65-
if ($token['code'] !== T_VARIABLE) {
66-
return false;
67-
}
88+
$tokens = $file->getTokens();
6889

69-
if (in_array($token['content'], self::SUPERGLOBALS, true)) {
70-
return true;
90+
while ($tokenPosition = $file->findPrevious(T_VARIABLE, $tokenPosition - 1, null, false, $name)) {
91+
$nextToken = $file->findNext(Tokens::$emptyTokens, $tokenPosition + 1, null, true, null, true);
92+
$nextToken = $tokens[$nextToken];
93+
94+
if ($nextToken['code'] === T_EQUAL) {
95+
return $tokenPosition;
96+
}
7197
}
7298

7399
return false;
74100
}
101+
102+
private function getArgumentTokens(File $file, int $start): array
103+
{
104+
$tokens = $file->getTokens();
105+
106+
if ($tokens[$start]['code'] === T_OPEN_PARENTHESIS) {
107+
$closingParenthesis = $tokens[$start]['parenthesis_closer'];
108+
109+
return array_slice($tokens, $start + 1, $closingParenthesis - $start);
110+
}
111+
112+
$endOfStatement = $file->findEndOfStatement($start);
113+
114+
return array_slice($tokens, $start, $endOfStatement - $start);
115+
}
75116
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
$target = $_GET['host'];
4+
5+
$foo = $target . ' foo';
6+
7+
shell_exec('ping ' . $foo);

Diff for: Security/Tests/Injection/OsInjectionUnitTest.php

+7-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@
88

99
final class OsInjectionUnitTest extends SecuritySniffTestCase
1010
{
11-
protected function getErrorList(): array
11+
protected function getErrorList(string $filename = ''): array
1212
{
13+
if ($filename === 'OsInjectionUnitTest.Indirect.inc') {
14+
return [
15+
7 => 1,
16+
];
17+
}
18+
1319
return [
1420
3 => 1,
1521
];

Diff for: Security/Tests/Utility/UserInputDetectorTest.php

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace NthRoot\PhpSecuritySniffs\Security\Tests\Utility;
6+
7+
use NthRoot\PhpSecuritySniffs\Security\Sniffs\UserInputDetector;
8+
use PHP_CodeSniffer\Config;
9+
use PHP_CodeSniffer\Files\LocalFile;
10+
use PHP_CodeSniffer\Ruleset;
11+
use PHPUnit\Framework\Attributes\DataProvider;
12+
use PHPUnit\Framework\TestCase;
13+
14+
use const T_STRING;
15+
use const T_VARIABLE;
16+
17+
class UserInputDetectorTest extends TestCase
18+
{
19+
private UserInputDetector $detector;
20+
21+
protected function setUp(): void
22+
{
23+
$this->detector = new UserInputDetector();
24+
}
25+
26+
#[DataProvider('getTaintedInputFiles')]
27+
public function testMarksSuperglobalsAsTainted(string $filename): void
28+
{
29+
$file = $this->createFile($filename);
30+
31+
$position = $file->findNext(T_VARIABLE, 0, null, false, '$_GET');
32+
33+
$this->assertTrue($this->detector->isTainted($file, $position));
34+
}
35+
36+
public static function getTaintedInputFiles(): array
37+
{
38+
return [
39+
['direct_input.php'],
40+
['concatenated_input.php'],
41+
];
42+
}
43+
44+
public function testMarksTaintedVariablesAsTainted(): void
45+
{
46+
$file = $this->createFile('indirect_input.php');
47+
48+
$position = $file->findNext(T_STRING, 0, null, false, 'shell_exec');
49+
$position = $file->findNext(T_VARIABLE, $position, null, false, '$target');
50+
51+
$this->assertTrue($this->detector->isTainted($file, $position));
52+
}
53+
54+
public function testDetectsDirectUserInput(): void
55+
{
56+
$file = $this->createFile('direct_input.php');
57+
58+
$start = $file->findNext(T_STRING, 0, null, false, 'shell_exec');
59+
60+
$this->assertTrue($this->detector->containsUserInput($file, $start));
61+
}
62+
63+
public function testDetectsIndirectUserInput(): void
64+
{
65+
$file = $this->createFile('indirect_input.php');
66+
67+
$start = $file->findNext(T_STRING, 0, null, false, 'shell_exec');
68+
69+
$this->assertTrue($this->detector->containsUserInput($file, $start));
70+
}
71+
72+
private function createFile(string $path): LocalFile
73+
{
74+
$config = new Config();
75+
76+
$path = __DIR__ . '/fixtures/' . $path;
77+
78+
$file = new LocalFile($path, new Ruleset($config), $config);
79+
$file->setContent(file_get_contents($path));
80+
$file->parse();
81+
82+
return $file;
83+
}
84+
}
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<?php
2+
3+
shell_exec('ping ' . $_GET['target']);

Diff for: Security/Tests/Utility/fixtures/direct_input.php

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<?php
2+
3+
shell_exec($_GET['target']);

Diff for: Security/Tests/Utility/fixtures/indirect_input.php

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
$target = $_GET['target'];
4+
5+
shell_exec('ping ' . $target);

Diff for: example/index.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
require_once $_GET['file'];
66

7-
shell_exec('ping ' . $_GET['host']);
7+
$target = $_GET['host'];
8+
9+
shell_exec('ping ' . $target);
810

911
echo file_get_contents($_GET['file']);
1012

0 commit comments

Comments
 (0)