Skip to content

Commit d20459d

Browse files
committed
Add new tests.
1 parent f21e23e commit d20459d

File tree

3 files changed

+370
-0
lines changed

3 files changed

+370
-0
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yousha\PhpSecurityLinter\Integration;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Yousha\PhpSecurityLinter\Linter;
9+
use Yousha\PhpSecurityLinter\Exceptions\LinterException;
10+
11+
/**
12+
* Integration Test for the Linter class.
13+
*
14+
* This test uses actual file operations and rule sets to verify the Linter's
15+
* behavior without relying on mocks.
16+
*/
17+
final class LinterIntegrationTest extends TestCase
18+
{
19+
private const string FIXTURES_DIR = __DIR__ . '/../fixtures/project_to_scan';
20+
21+
/**
22+
* Set up a temporary directory structure for testing.
23+
*/
24+
protected function setUp(): void
25+
{
26+
// 1. Create the fixture directory
27+
if (!is_dir(self::FIXTURES_DIR)) {
28+
mkdir(self::FIXTURES_DIR, 0o777, true);
29+
}
30+
31+
// 2. Create files with known issues and non-issues
32+
33+
// File 1: Contains an OWASP-002 (Command Injection) and CIS-003 (Directory Traversal)
34+
file_put_contents(self::FIXTURES_DIR . '/bad_code.php', <<<PHP
35+
<?php
36+
// OWASP-002: OS Command Injection risk
37+
\$cmd = 'ls ' . \$_GET['dir'];
38+
shell_exec(\$cmd);
39+
40+
// CIS-003: Directory traversal vulnerability
41+
\$file = \$_GET['f'];
42+
include_once __DIR__ . '/../../views/' . \$file . '.php'; // Traversal check with /../../
43+
?>
44+
PHP);
45+
46+
// File 2: Contains a harmless file that should not trigger any rule
47+
file_put_contents(self::FIXTURES_DIR . '/clean_code.php', <<<PHP
48+
<?php
49+
declare(strict_types=1);
50+
class Clean {
51+
public function safeMethod(string \$data): bool {
52+
return password_verify(\$data, 'hash');
53+
}
54+
}
55+
?>
56+
PHP);
57+
58+
// 3. Create a subdirectory with an ignored file
59+
mkdir(self::FIXTURES_DIR . '/sub_dir', 0o777, true);
60+
file_put_contents(self::FIXTURES_DIR . '/sub_dir/ignored_file.txt', 'This should be ignored.');
61+
62+
// 4. Create a default excluded directory (e.g., vendor)
63+
mkdir(self::FIXTURES_DIR . '/vendor', 0o777, true);
64+
file_put_contents(self::FIXTURES_DIR . '/vendor/autoload.php', '<?php // vendor code');
65+
}
66+
67+
/**
68+
* Clean up the temporary directory structure after tests.
69+
*/
70+
protected function tearDown(): void
71+
{
72+
$this->removeDirectory(self::FIXTURES_DIR);
73+
}
74+
75+
/**
76+
* Helper to recursively remove a directory.
77+
*/
78+
private function removeDirectory(string $dir): void
79+
{
80+
if (!is_dir($dir)) {
81+
return;
82+
}
83+
84+
$files = array_diff(scandir($dir), ['.', '..']);
85+
foreach ($files as $file) {
86+
(is_dir(sprintf('%s/%s', $dir, $file))) ? $this->removeDirectory(sprintf('%s/%s', $dir, $file)) : unlink(sprintf('%s/%s', $dir, $file));
87+
}
88+
89+
rmdir($dir);
90+
}
91+
92+
/**
93+
* Test that the hardcoded 'vendor' exclusion works.
94+
*/
95+
public function testDefaultVendorExclusion(): void
96+
{
97+
// The Linter's scan method relies on bin/php-sl.php to inject 'vendor' and '.git'
98+
// into the $exclude array. Since we are testing Linter directly, we must manually
99+
// pass the default exclusions.
100+
101+
$defaultExclusions = ['vendor', '.git'];
102+
$linter = new Linter();
103+
$results = $linter->scan(self::FIXTURES_DIR, $defaultExclusions);
104+
105+
// Check the total scanned count. It should only count bad_code.php and clean_code.php
106+
// (The vendor directory is ignored, the sub_dir/ignored_file.txt is ignored because it's not .php)
107+
$this->assertArrayHasKey('_meta', $results);
108+
109+
// Assert that the file inside 'vendor' was not counted
110+
$this->assertSame(2, $results['_meta']['scanned_count']);
111+
}
112+
113+
/**
114+
* Test Linter throws LinterException for non-existent path.
115+
*/
116+
public function testScanThrowsExceptionForNonExistentPath(): void
117+
{
118+
$linter = new Linter();
119+
$this->expectException(LinterException::class);
120+
$this->expectExceptionMessage('Path does not exist:');
121+
122+
$linter->scan('/non/existent/path/for/linter/test');
123+
}
124+
}

tests/Unit/Rules/CisRulesTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yousha\PhpSecurityLinter\Unit\Rules;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Yousha\PhpSecurityLinter\Rules\CisRules;
9+
10+
final class CisRulesTest extends TestCase
11+
{
12+
private array $rules;
13+
14+
protected function setUp(): void
15+
{
16+
$this->rules = CisRules::getRules();
17+
}
18+
19+
/**
20+
* Test that the rules array is correctly structured and not empty.
21+
*/
22+
public function testRulesArrayStructure(): void
23+
{
24+
$this->assertIsArray($this->rules);
25+
$this->assertNotEmpty($this->rules);
26+
27+
foreach ($this->rules as $rule) {
28+
$this->assertArrayHasKey('id', $rule);
29+
$this->assertIsString($rule['id']);
30+
$this->assertStringStartsWith('CIS-', $rule['id']);
31+
32+
$this->assertArrayHasKey('severity', $rule);
33+
$this->assertIsString($rule['severity']);
34+
35+
$this->assertArrayHasKey('message', $rule);
36+
$this->assertIsString($rule['message']);
37+
38+
$this->assertArrayHasKey('pattern', $rule);
39+
$this->assertIsString($rule['pattern']);
40+
}
41+
}
42+
43+
/**
44+
* Data provider for vulnerable and clean code snippets for each CIS rule.
45+
* * The array structure is: [rule_id, vulnerable_code, clean_code]
46+
*/
47+
public static function ruleDataProvider(): array
48+
{
49+
return [
50+
// CIS-001: Direct call to dangerous function detected
51+
['CIS-001', '<?php eval($code); system("ls");', '<?php if (true) { /* no exec */ }'],
52+
53+
// CIS-002: Error reporting exposes stack traces
54+
['CIS-002', '<?php display_errors(true);', '<?php ini_set("display_errors", "Off");'],
55+
56+
// CIS-003: Directory traversal vulnerability
57+
['CIS-003', '<?php include(__DIR__ . "/../../config.php");', '<?php require_once("config.php");'],
58+
59+
// CIS-004: Unsafe temporary file creation
60+
['CIS-004', '<?php $tmp = tempnam("/tmp", "prefix");', '<?php $safe = sys_get_temp_dir();'],
61+
62+
// CIS-005: Session fixation possible
63+
['CIS-005', '<?php session_start(); $_SESSION["user"] = 1;', '<?php session_start(); session_regenerate_id(true);'],
64+
65+
// CIS-006: Weak hash function detected
66+
['CIS-006', '<?php $hash = md5($password);', '<?php $hash = password_hash($password, PASSWORD_DEFAULT);'],
67+
68+
// CIS-007: Hardcoded encryption keys
69+
['CIS-007', '<?php $key = "a1b2c3d4e5f6g7h8i9j0";', '<?php $key = $_ENV["SECRET_KEY"];'],
70+
71+
// CIS-008: Raw SQL with user input
72+
['CIS-008', '<?php mysqli_query($db, "SELECT * FROM users WHERE id=" . $_GET["id"]);', '<?php $stmt->execute([$id]);'],
73+
74+
// CIS-009: Unvalidated redirect
75+
['CIS-009', '<?php header("Location: " . $_POST["url"]);', '<?php header("Location: /dashboard");'],
76+
77+
// CIS-011: Dangerous use of extract() with user input
78+
['CIS-011', '<?php extract($_REQUEST);', '<?php extract($data, EXTR_SKIP);'],
79+
80+
// CIS-010: SSL verification disabled
81+
['CIS-010', '<?php curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);', '<?php curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);'],
82+
];
83+
}
84+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yousha\PhpSecurityLinter\Tests\Unit\Rules;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Yousha\PhpSecurityLinter\Rules\OwaspRules;
9+
10+
final class OwaspRulesTest extends TestCase
11+
{
12+
private array $rules;
13+
14+
protected function setUp(): void
15+
{
16+
parent::setUp();
17+
$this->rules = OwaspRules::getRules();
18+
}
19+
20+
public function ruleDataProvider(): array
21+
{
22+
$rules = OwaspRules::getRules();
23+
$data = [];
24+
foreach ($rules as $rule) {
25+
// Extract the ID from the message
26+
$idMatch = [];
27+
preg_match('/^(OWASP-[A-Z0-9-]+):/', (string) $rule['message'], $idMatch);
28+
$id = $idMatch[1] ?? 'unknown';
29+
$data[] = [$rule, $id];
30+
}
31+
32+
return $data;
33+
}
34+
35+
public function testOwaspA07WeakPasswordHashPattern(): void
36+
{
37+
$pattern = null;
38+
foreach ($this->rules as $rule) {
39+
if (str_starts_with((string) $rule['message'], 'OWASP-A07:')) {
40+
$pattern = $rule['pattern'];
41+
break;
42+
}
43+
}
44+
45+
$this->assertNotNull($pattern, 'OWASP-A07 (Weak Hash) rule not found');
46+
47+
$vulnerableCode1 = '<?php $hash = hash("md5", $password); ?>';
48+
$vulnerableCode2 = '<?php $hash = hash("sha1", $data); ?>';
49+
$vulnerableCode3 = '<?php $hash = hash("md2", $input); ?>';
50+
$vulnerableCode4 = '<?php $hash = hash("md4", $input); ?>';
51+
$vulnerableCode5 = '<?php $hash = hash("sha224", $input); ?>';
52+
53+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode1);
54+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode2);
55+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode3);
56+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode4);
57+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode5);
58+
59+
$safeCode1 = '<?php $hash = hash("sha256", $password); ?>';
60+
$safeCode2 = '<?php $hash = hash("sha512", $data); ?>';
61+
$safeCode3 = '<?php $hash = password_hash($password, PASSWORD_DEFAULT); ?>';
62+
63+
$this->assertDoesNotMatchRegularExpression($pattern, $safeCode1);
64+
$this->assertDoesNotMatchRegularExpression($pattern, $safeCode2);
65+
$this->assertDoesNotMatchRegularExpression($pattern, $safeCode3);
66+
}
67+
68+
public function testOwaspA08InsecureDeserializationUnserializePattern(): void
69+
{
70+
$pattern = null;
71+
foreach ($this->rules as $rule) {
72+
if (str_starts_with((string) $rule['message'], 'OWASP-A08:')) {
73+
if (str_contains((string) $rule['message'], 'unserialize')) {
74+
$pattern = $rule['pattern'];
75+
break;
76+
}
77+
}
78+
}
79+
80+
$this->assertNotNull($pattern, 'OWASP-A08 (unserialize) rule not found');
81+
82+
$vulnerableCode1 = '<?php $data = unserialize($_GET["obj"]); ?>';
83+
$vulnerableCode2 = '<?php $data = unserialize($_POST["data"]); ?>';
84+
$vulnerableCode3 = '<?php $data = unserialize($_REQUEST["input"]); ?>';
85+
$vulnerableCode4 = '<?php $data = unserialize($_COOKIE["stored"]); ?>';
86+
87+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode1);
88+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode2);
89+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode3);
90+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode4);
91+
92+
// Safe code should not match
93+
$safeCode1 = '<?php $data = unserialize($trustedData); ?>'; // Variable, not $_GET/POST
94+
$safeCode2 = '<?php $data = unserialize("a:1:{s:5:\"hello\";s:5:\"world\";}"); ?>'; // Hardcoded string
95+
96+
$this->assertDoesNotMatchRegularExpression($pattern, $safeCode1);
97+
$this->assertDoesNotMatchRegularExpression($pattern, $safeCode2);
98+
}
99+
100+
public function testOwaspA10SsrFPattern(): void
101+
{
102+
$pattern = null;
103+
foreach ($this->rules as $rule) {
104+
if (str_starts_with((string) $rule['message'], 'OWASP-A10:')) {
105+
$pattern = $rule['pattern'];
106+
break;
107+
}
108+
}
109+
110+
$this->assertNotNull($pattern, 'OWASP-A10 rule not found');
111+
112+
$vulnerableCode1 = '<?php $content = file_get_contents($_GET["url"]); ?>';
113+
$vulnerableCode2 = '<?php $fp = fopen($_POST["resource"], "r"); ?>';
114+
$vulnerableCode3 = '<?php $ch = curl_init($_REQUEST["endpoint"]); ?>';
115+
$vulnerableCode4 = '<?php $fp = fsockopen($_GET["host"], 80); ?>';
116+
$vulnerableCode5 = '<?php $fp = stream_socket_client("tcp://" . $_POST["address"] . ":80"); ?>';
117+
118+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode1);
119+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode2);
120+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode3);
121+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode4);
122+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode5);
123+
124+
// Safe code should not match
125+
$safeCode1 = '<?php $content = file_get_contents("https://api.example.com/data"); ?>'; // Hardcoded URL
126+
$safeCode2 = '<?php $fp = fopen($validatedUrl, "r"); ?>'; // Variable, not $_GET/POST
127+
128+
$this->assertDoesNotMatchRegularExpression($pattern, $safeCode1);
129+
$this->assertDoesNotMatchRegularExpression($pattern, $safeCode2);
130+
}
131+
132+
public function testOwaspA07ReflectedXssPattern(): void
133+
{
134+
$pattern = null;
135+
foreach ($this->rules as $rule) {
136+
if (str_starts_with((string) $rule['message'], 'OWASP-A07:')) {
137+
if (str_contains((string) $rule['message'], 'Reflected XSS')) {
138+
$pattern = $rule['pattern'];
139+
break;
140+
}
141+
}
142+
}
143+
144+
$this->assertNotNull($pattern, 'OWASP-A07 (Reflected XSS) rule not found');
145+
146+
$vulnerableCode1 = '<?php echo $_GET["name"]; ?>';
147+
$vulnerableCode2 = '<?php echo $_POST["comment"]; ?>';
148+
$vulnerableCode3 = '<?php echo $_REQUEST["input"]; ?>';
149+
$vulnerableCode4 = '<?php echo $_COOKIE["username"]; ?>';
150+
151+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode1);
152+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode2);
153+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode3);
154+
$this->assertMatchesRegularExpression($pattern, $vulnerableCode4);
155+
156+
// Safe code should not match (or might need more specific pattern)
157+
$safeCode1 = '<?php echo htmlspecialchars($_GET["name"]); ?>'; // Output is sanitized
158+
159+
$safeCode2 = '<?php echo $safeVariable; ?>'; // Variable, not $_GET/POST
160+
$this->assertDoesNotMatchRegularExpression($pattern, $safeCode2);
161+
}
162+
}

0 commit comments

Comments
 (0)