diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3864890 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "cacti/plugin_evidence", + "description": "plugin_evidence plugin for Cacti", + "license": "GPL-2.0-or-later", + "require-dev": { + "pestphp/pest": "^1.23" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "autoload-dev": { + "files": [ + "tests/bootstrap.php" + ] + } +} diff --git a/evidence_tab.php b/evidence_tab.php index fbf7caf..ebac55f 100644 --- a/evidence_tab.php +++ b/evidence_tab.php @@ -119,9 +119,9 @@ function evidence_display_form() { if (cacti_sizeof($scan_dates)) { foreach ($scan_dates as $scan_date) { - print ''; + '>' . html_escape($scan_date) . ''; } } @@ -142,7 +142,7 @@ function evidence_display_form() { print ''; print ''; - print ''; + print ''; print ''; print ''; print __('Specify data type'); @@ -157,7 +157,7 @@ function evidence_display_form() { print ''; foreach ($entities as $key => $value) { - print ''; + print ''; } print ''; @@ -260,4 +260,3 @@ function evidence_stats() { print 'Oldest record: ' . $old . '
'; } - diff --git a/setup.php b/setup.php index c3b671c..4209b1d 100644 --- a/setup.php +++ b/setup.php @@ -42,26 +42,12 @@ function plugin_evidence_install () { function plugin_evidence_uninstall () { - - if (sizeof(db_fetch_assoc("SHOW TABLES LIKE 'plugin_evidence_specific_query'")) > 0 ) { - db_execute("DROP TABLE `plugin_evidence_specific_query`"); - } - - if (sizeof(db_fetch_assoc("SHOW TABLES LIKE 'plugin_evidence_organization'")) > 0 ) { - db_execute("DROP TABLE `plugin_evidence_organization`"); - } - - if (sizeof(db_fetch_assoc("SHOW TABLES LIKE 'plugin_evidence_entity'")) > 0 ) { - db_execute("DROP TABLE `plugin_evidence_entity`"); - } - - if (sizeof(db_fetch_assoc("SHOW TABLES LIKE 'plugin_evidence_mac'")) > 0 ) { - db_execute("DROP TABLE `plugin_evidence_mac`"); - } - - if (sizeof(db_fetch_assoc("SHOW TABLES LIKE 'plugin_evidence_vendor_specific'")) > 0 ) { - db_execute("DROP TABLE `plugin_evidence_vendor_specific`"); - } + db_execute_prepared('DROP TABLE IF EXISTS `plugin_evidence_specific_query`', []); + db_execute_prepared('DROP TABLE IF EXISTS `plugin_evidence_organization`', []); + db_execute_prepared('DROP TABLE IF EXISTS `plugin_evidence_entity`', []); + db_execute_prepared('DROP TABLE IF EXISTS `plugin_evidence_mac`', []); + db_execute_prepared('DROP TABLE IF EXISTS `plugin_evidence_ip`', []); + db_execute_prepared('DROP TABLE IF EXISTS `plugin_evidence_vendor_specific`', []); } diff --git a/tests/E2E/EvidenceFilterXssRegressionTest.php b/tests/E2E/EvidenceFilterXssRegressionTest.php new file mode 100644 index 0000000..d32213d --- /dev/null +++ b/tests/E2E/EvidenceFilterXssRegressionTest.php @@ -0,0 +1,17 @@ +not->toContain('value="' . "' . get_request_var('find_text') . '"); + expect($contents)->toContain('value="' . "' . html_escape(get_request_var('find_text')) . '"); + }); +}); diff --git a/tests/Integration/EvidenceTabEscapingTest.php b/tests/Integration/EvidenceTabEscapingTest.php new file mode 100644 index 0000000..0cc70b9 --- /dev/null +++ b/tests/Integration/EvidenceTabEscapingTest.php @@ -0,0 +1,23 @@ +toContain('html_escape($scan_date)'); + }); + + it('escapes entity keys and values rendered into select options', function () { + $contents = file_get_contents(realpath(__DIR__ . '/../../evidence_tab.php')); + + expect($contents)->toContain('html_escape($key)'); + expect($contents)->toContain('html_escape($value)'); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..e6bf268 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,10 @@ +toBeTrue( + "File {$relativeFile} does not include auth.php or global.php" + ); + } + }); + + it('validates numeric IDs from request variables before DB queries', function () { + $uiFiles = array( + 'tests/test_prepared_statements.php', + ); + + foreach ($uiFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + if ($path === false) continue; + $contents = file_get_contents($path); + if ($contents === false) continue; + + // Check for get_filter_request_var usage for numeric IDs + if (preg_match('/get_request_var\s*\(\s*[\'\"]id[\'\"]/', $contents)) { + // Should use get_filter_request_var for 'id' params + $hasFilter = ( + strpos($contents, 'get_filter_request_var') !== false || + strpos($contents, 'input_validate_input_number') !== false || + strpos($contents, 'form_input_validate') !== false + ); + + expect($hasFilter)->toBeTrue( + "File {$relativeFile} uses get_request_var for IDs without validation" + ); + } + } + }); +}); diff --git a/tests/Security/OutputEscapingTest.php b/tests/Security/OutputEscapingTest.php new file mode 100644 index 0000000..a9b7294 --- /dev/null +++ b/tests/Security/OutputEscapingTest.php @@ -0,0 +1,76 @@ +toBe(0, + "File {$relativeFile} has unescaped variables in HTML attributes" + ); + } + }); + + it('uses html_escape or __esc for user-controlled output', function () { + $uiFiles = array( + 'evidence_tab.php', + ); + + $totalEscapeCalls = 0; + + foreach ($uiFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + if ($path === false) continue; + $contents = file_get_contents($path); + if ($contents === false) continue; + + $totalEscapeCalls += preg_match_all('/html_escape|__esc\(|htmlspecialchars/', $contents); + } + + // At least some escaping should be present in UI files + expect($totalEscapeCalls)->toBeGreaterThan(0, + 'UI files should contain at least one html_escape/__esc call' + ); + }); + + it('escapes the evidence filter text before rendering it into the HTML value attribute', function () { + $contents = file_get_contents(realpath(__DIR__ . '/../../evidence_tab.php')); + + expect($contents)->toContain( + 'html_escape(get_request_var(\'find_text\'))' + ); + }); +}); diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php new file mode 100644 index 0000000..13722bf --- /dev/null +++ b/tests/Security/Php74CompatibilityTest.php @@ -0,0 +1,113 @@ +toBe(0, "{$f} uses str_contains"); + } + }); + + it('does not use str_starts_with (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/\bstr_starts_with\s*\(/', $c))->toBe(0, "{$f} uses str_starts_with"); + } + }); + + it('does not use str_ends_with (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/\bstr_ends_with\s*\(/', $c))->toBe(0, "{$f} uses str_ends_with"); + } + }); + + it('does not use nullsafe operator (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/\?->/', $c))->toBe(0, "{$f} uses nullsafe operator"); + } + }); + + it('does not use match expression (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + // Avoid false positive on preg_match etc + $c2 = preg_replace('/preg_match|preg_match_all|fnmatch/', '', $c); + expect(preg_match('/\bmatch\s*\(/', $c2))->toBe(0, "{$f} uses match expression"); + } + }); + + it('does not use union type declarations (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + // Match function params/return with union types like string|false + $hits = preg_match_all('/function\s+\w+\s*\([^)]*\w+\s*\|\s*\w+/', $c); + expect($hits)->toBe(0, "{$f} uses union types in function signatures"); + } + }); + + it('does not use constructor property promotion (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/function\s+__construct\s*\([^)]*\b(public|private|protected|readonly)\s/', $c))->toBe(0, + "{$f} uses constructor promotion" + ); + } + }); + + it('uses array() not short syntax for new arrays', function () use ($files) { + // This is a style preference for 1.2.x consistency, not a hard requirement + // Just verify no mixed styles in the same file + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + + $hasArrayFunc = preg_match('/\barray\s*\(/', $c); + $hasShortArray = preg_match('/=\s*\[/', $c); + + // Flag files that mix both styles + if ($hasArrayFunc && $hasShortArray) { + // Allow mixed if the file existed before our changes + // This is informational, not a hard fail + } + } + + expect(true)->toBeTrue(); + }); +}); diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php new file mode 100644 index 0000000..8f33831 --- /dev/null +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -0,0 +1,75 @@ +toBe(0, "File {$relativeFile} contains raw DB calls"); + } + }); + + it('uses parameterized placeholders not string interpolation in SQL', function () { + $targetFiles = array( + 'setup.php', + 'tests/test_prepared_statements.php', + ); + + foreach ($targetFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + if ($path === false) continue; + $contents = file_get_contents($path); + if ($contents === false) continue; + + $lines = explode("\n", $contents); + $interpolatedSql = 0; + + foreach ($lines as $num => $line) { + $trimmed = ltrim($line); + if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0) continue; + + // Detect _prepared calls with $ interpolation instead of ? placeholders + if (preg_match('/_prepared\s*\(/', $line) && preg_match('/\$[a-zA-Z_]/', $line)) { + // Allow array($var) param binding but flag "WHERE id = $var" + if (preg_match('/(?:SELECT|INSERT|UPDATE|DELETE|WHERE|SET|FROM|JOIN).*\$/', $line)) { + $interpolatedSql++; + } + } + } + + // This is a heuristic; some false positives expected for complex queries + expect($interpolatedSql)->toBeLessThanOrEqual(2, + "File {$relativeFile} may have SQL interpolation in prepared calls" + ); + } + }); +}); diff --git a/tests/Security/RedirectSafetyTest.php b/tests/Security/RedirectSafetyTest.php new file mode 100644 index 0000000..c33284c --- /dev/null +++ b/tests/Security/RedirectSafetyTest.php @@ -0,0 +1,50 @@ +toBe(0, + "File {$relativeFile} has header(Location) without exit/die" + ); + } + }); +}); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php new file mode 100644 index 0000000..fa814e7 --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,36 @@ +toContain('function plugin_evidence_install'); + }); + + it('defines plugin_evidence_version function', function () use ($source) { + expect($source)->toContain('function plugin_evidence_version'); + }); + + it('defines plugin_evidence_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_evidence_uninstall'); + }); + + it('returns version array with name key', function () use ($source) { + expect($source)->toMatch('/[\'\""]name[\'\""]\s*=>/'); + }); + + it('returns version array with version key', function () use ($source) { + expect($source)->toMatch('/[\'\""]version[\'\""]\s*=>/'); + }); + + it('registers hooks in install function', function () use ($source) { + expect($source)->toContain('api_plugin_register_hook'); + }); +}); diff --git a/tests/Unit/FilterOutputEscapingTest.php b/tests/Unit/FilterOutputEscapingTest.php new file mode 100644 index 0000000..8fb00d9 --- /dev/null +++ b/tests/Unit/FilterOutputEscapingTest.php @@ -0,0 +1,20 @@ +alert(1)'; + + $escaped = html_escape($payload); + + expect($escaped)->not->toContain('