diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..499c4d3 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "cacti/plugin_quicktree", + "description": "plugin_quicktree 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/quicktree.php b/quicktree.php index b9b8dff..1e49f2e 100644 --- a/quicktree.php +++ b/quicktree.php @@ -27,6 +27,7 @@ chdir('../../'); include_once('include/auth.php'); +include_once('plugins/quicktree/quicktree_security.php'); define('QUICKTREE_BASE_URI', $config['url_path'] . 'plugins/quicktree/'); @@ -45,6 +46,7 @@ set_default_action(); $action = get_request_var('action'); +$location = quicktree_normalize_location(get_nfilter_request_var('location')); $user = $_SESSION['sess_user_id']; /* ================= input validation ================= */ @@ -55,8 +57,6 @@ $action = $code_actions[$drp_action]; } -header('action_3_new: '. $action); - switch ($action) { case 'add': $graph = 0; @@ -152,9 +152,10 @@ html_start_box($form_actions[$drp_action], '60%', '', '3', 'center', ''); - $queryrows = db_fetch_assoc("SELECT g.id, g.name + $queryrows = db_fetch_assoc_prepared('SELECT g.id, g.name FROM graph_tree AS g - ORDER BY g.name"); + ORDER BY g.name', + array()); print ''; @@ -205,7 +206,7 @@ case 'clear': $SQL = db_execute_prepared('DELETE FROM quicktree_graphs WHERE userid = ?', array($user)); - header('Location: quicktree.php?header=false&drp_action=&action=&location=' . get_nfilter_request_var('location')); + header('Location: quicktree.php?header=false&drp_action=&action=&location=' . $location); break; case 'save': @@ -252,7 +253,9 @@ include_once($config['base_path'] . '/lib/api_tree.php'); if (empty($new_tree_id)) { - $seq = db_fetch_cell('SELECT MAX(sequence) FROM graph_tree'); + $seq = db_fetch_cell_prepared('SELECT MAX(sequence) + FROM graph_tree', + array()); if ($seq == NULL || $seq < 0) { $seq = 1; @@ -333,7 +336,7 @@ array($user, $graph)); } - header('Location: quicktree.php?location=' . get_nfilter_request_var('location')); + header('Location: quicktree.php?location=' . $location); break; case 'add_ajax': @@ -343,13 +346,13 @@ break; default: - if (get_nfilter_request_var('location') == 'console') { + if ($location == 'console') { top_header(); } else { general_header(); } - form_start('quicktree.php?location=' . get_nfilter_request_var('location'), 'quicktree_form'); + form_start('quicktree.php?location=' . $location, 'quicktree_form'); html_start_box(__('QuickTree', 'quicktree'), '100%', true, '3', 'center', ''); print "
@@ -411,7 +414,7 @@ print ''; @@ -430,4 +433,3 @@ break; } - diff --git a/quicktree_security.php b/quicktree_security.php new file mode 100644 index 0000000..2d6397a --- /dev/null +++ b/quicktree_security.php @@ -0,0 +1,17 @@ +not->toBeFalse(); + + $contents = file_get_contents($path); + expect($contents)->not->toBeFalse(); + + expect($contents)->not->toContain("header('action_3_new: '. \$action);"); + }); +}); diff --git a/tests/Integration/QuicktreeRedirectSafetyTest.php b/tests/Integration/QuicktreeRedirectSafetyTest.php new file mode 100644 index 0000000..df6bc1f --- /dev/null +++ b/tests/Integration/QuicktreeRedirectSafetyTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + + $contents = file_get_contents($path); + expect($contents)->not->toBeFalse(); + + expect($contents)->toContain("include_once('plugins/quicktree/quicktree_security.php');"); + expect($contents)->toContain("\$location = quicktree_normalize_location(get_nfilter_request_var('location'));"); + expect($contents)->toContain("header('Location: quicktree.php?location=' . \$location);"); + expect($contents)->toContain("form_start('quicktree.php?location=' . \$location, 'quicktree_form');"); + }); +}); 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( + 'quicktree.php', + '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..b4b9b3f --- /dev/null +++ b/tests/Security/OutputEscapingTest.php @@ -0,0 +1,70 @@ +toBe(0, + "File {$relativeFile} has unescaped variables in HTML attributes" + ); + } + }); + + it('uses html_escape or __esc for user-controlled output', function () { + $uiFiles = array( + 'quicktree.php', + 'tests/test_prepared_statements.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' + ); + }); +}); diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php new file mode 100644 index 0000000..9926a86 --- /dev/null +++ b/tests/Security/Php74CompatibilityTest.php @@ -0,0 +1,114 @@ +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..67b204d --- /dev/null +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -0,0 +1,77 @@ +toBe(0, "File {$relativeFile} contains raw DB calls"); + } + }); + + it('uses parameterized placeholders not string interpolation in SQL', function () { + $targetFiles = array( + 'quicktree.php', + '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..8fc99d0 --- /dev/null +++ b/tests/Security/RedirectSafetyTest.php @@ -0,0 +1,51 @@ +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..ea67d81 --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,36 @@ +toContain('function plugin_quicktree_install'); + }); + + it('defines plugin_quicktree_version function', function () use ($source) { + expect($source)->toContain('function plugin_quicktree_version'); + }); + + it('defines plugin_quicktree_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_quicktree_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/LocationNormalizationTest.php b/tests/Unit/LocationNormalizationTest.php new file mode 100644 index 0000000..7a0d175 --- /dev/null +++ b/tests/Unit/LocationNormalizationTest.php @@ -0,0 +1,19 @@ +toBe('console'); + expect(quicktree_normalize_location('tab'))->toBe('tab'); + expect(quicktree_normalize_location('console%0d%0aX-Test:1'))->toBe('tab'); + expect(quicktree_normalize_location('../../evil'))->toBe('tab'); + }); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..3cc3724 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,54 @@ + 'db_execute', 'sql' => $sql, 'params' => array()); + return true; + } +} +if (!function_exists('db_execute_prepared')) { + function db_execute_prepared($sql, $params = array()) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params); + return true; + } +} +if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { return array(); } } +if (!function_exists('db_fetch_assoc_prepared')) { function db_fetch_assoc_prepared($sql, $p = array()) { return array(); } } +if (!function_exists('db_fetch_row')) { function db_fetch_row($sql) { return array(); } } +if (!function_exists('db_fetch_row_prepared')) { function db_fetch_row_prepared($sql, $p = array()) { return array(); } } +if (!function_exists('db_fetch_cell')) { function db_fetch_cell($sql) { return ''; } } +if (!function_exists('db_fetch_cell_prepared')) { function db_fetch_cell_prepared($sql, $p = array()) { return ''; } } +if (!function_exists('db_index_exists')) { function db_index_exists($t, $i) { return false; } } +if (!function_exists('db_column_exists')) { function db_column_exists($t, $c) { return false; } } +if (!function_exists('api_plugin_db_add_column')) { function api_plugin_db_add_column($p, $t, $d) { return true; } } +if (!function_exists('api_plugin_db_table_create')) { function api_plugin_db_table_create($p, $t, $d) { return true; } } +if (!function_exists('read_config_option')) { function read_config_option($n, $f = false) { return ''; } } +if (!function_exists('set_config_option')) { function set_config_option($n, $v) {} } +if (!function_exists('html_escape')) { function html_escape($s) { return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } } +if (!function_exists('__')) { function __($t, $d = '') { return $t; } } +if (!function_exists('__esc')) { function __esc($t, $d = '') { return htmlspecialchars($t, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } } +if (!function_exists('cacti_log')) { function cacti_log($m, $p = false, $t = '', $l = 0) {} } +if (!function_exists('cacti_sizeof')) { function cacti_sizeof($a) { return is_array($a) ? count($a) : 0; } } +if (!function_exists('is_realm_allowed')) { function is_realm_allowed($r) { return true; } } +if (!function_exists('raise_message')) { function raise_message($i, $t = '', $l = 0) {} } +if (!function_exists('get_request_var')) { function get_request_var($n) { return ''; } } +if (!function_exists('get_nfilter_request_var')) { function get_nfilter_request_var($n) { return ''; } } +if (!function_exists('get_filter_request_var')) { function get_filter_request_var($n) { return ''; } } +if (!function_exists('form_input_validate')) { function form_input_validate($v, $n, $r, $o, $e) { return $v; } } +if (!function_exists('is_error_message')) { function is_error_message() { return false; } } +if (!function_exists('sql_save')) { function sql_save($a, $t, $k = 'id') { return isset($a['id']) ? $a['id'] : 1; } } +if (!defined('CACTI_PATH_BASE')) { define('CACTI_PATH_BASE', '/var/www/html/cacti'); } +if (!defined('POLLER_VERBOSITY_LOW')) { define('POLLER_VERBOSITY_LOW', 2); } +if (!defined('POLLER_VERBOSITY_MEDIUM')) { define('POLLER_VERBOSITY_MEDIUM', 3); } +if (!defined('POLLER_VERBOSITY_DEBUG')) { define('POLLER_VERBOSITY_DEBUG', 5); } +if (!defined('POLLER_VERBOSITY_NONE')) { define('POLLER_VERBOSITY_NONE', 6); } +if (!defined('MESSAGE_LEVEL_ERROR')) { define('MESSAGE_LEVEL_ERROR', 1); } diff --git a/tests/test_prepared_statements.php b/tests/test_prepared_statements.php new file mode 100644 index 0000000..ee3036f --- /dev/null +++ b/tests/test_prepared_statements.php @@ -0,0 +1,75 @@ + 0 ? 1 : 0);
' . $graph_title; - print '  '; print '