From 399da40c5777a46d96dc1d11a93f89983c74a237 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Wed, 8 Apr 2026 23:56:28 -0700 Subject: [PATCH 1/4] test: add Pest v1 security test infrastructure Add source-scan tests verifying security patterns (prepared statements, output escaping, auth guards, PHP 7.4 compatibility) remain in place across refactors. Tests run with Pest v1 (PHP 7.3+) and stub the Cacti framework so plugins can be tested in isolation. Signed-off-by: Thomas Vincent --- composer.json | 18 ++ tests/Pest.php | 14 ++ tests/Security/Php74CompatibilityTest.php | 106 ++++++++++ .../PreparedStatementConsistencyTest.php | 64 ++++++ tests/Security/SetupStructureTest.php | 36 ++++ tests/bootstrap.php | 200 ++++++++++++++++++ 6 files changed, 438 insertions(+) create mode 100644 composer.json create mode 100644 tests/Pest.php create mode 100644 tests/Security/Php74CompatibilityTest.php create mode 100644 tests/Security/PreparedStatementConsistencyTest.php create mode 100644 tests/Security/SetupStructureTest.php create mode 100644 tests/bootstrap.php diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..73ddafe --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "cacti/plugin_reportit", + "description": "plugin_reportit 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/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..e2132a3 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,14 @@ +toBe(0, + "{$relativeFile} uses str_contains() which requires PHP 8.0" + ); + } + }); + + it('does not use str_starts_with (PHP 8.0)', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + if ($path === false) { + continue; + } + + $contents = file_get_contents($path); + + if ($contents === false) { + continue; + } + + expect(preg_match('/\bstr_starts_with\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses str_starts_with() which requires PHP 8.0" + ); + } + }); + + it('does not use str_ends_with (PHP 8.0)', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + if ($path === false) { + continue; + } + + $contents = file_get_contents($path); + + if ($contents === false) { + continue; + } + + expect(preg_match('/\bstr_ends_with\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses str_ends_with() which requires PHP 8.0" + ); + } + }); + + it('does not use nullsafe operator (PHP 8.0)', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + if ($path === false) { + continue; + } + + $contents = file_get_contents($path); + + if ($contents === false) { + continue; + } + + expect(preg_match('/\?->/', $contents))->toBe(0, + "{$relativeFile} uses nullsafe operator which requires PHP 8.0" + ); + } + }); +}); diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php new file mode 100644 index 0000000..fcdd2ab --- /dev/null +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -0,0 +1,64 @@ +toBe(0, + "File {$relativeFile} contains raw (unprepared) DB calls" + ); + } + }); +}); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php new file mode 100644 index 0000000..50b4313 --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,36 @@ +toContain('function plugin_reportit_install'); + }); + + it('defines plugin_reportit_version function', function () use ($source) { + expect($source)->toContain('function plugin_reportit_version'); + }); + + it('defines plugin_reportit_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_reportit_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*=>/'); + }); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..0aa22f2 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,200 @@ + '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, $params = 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, $params = 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, $params = array()) { + return ''; + } +} + +if (!function_exists('db_index_exists')) { + function db_index_exists($table, $index) { + return false; + } +} + +if (!function_exists('db_column_exists')) { + function db_column_exists($table, $column) { + return false; + } +} + +if (!function_exists('api_plugin_db_add_column')) { + function api_plugin_db_add_column($plugin, $table, $data) { + return true; + } +} + +if (!function_exists('api_plugin_db_table_create')) { + function api_plugin_db_table_create($plugin, $table, $data) { + return true; + } +} + +if (!function_exists('read_config_option')) { + function read_config_option($name, $force = false) { + return ''; + } +} + +if (!function_exists('set_config_option')) { + function set_config_option($name, $value) { + } +} + +if (!function_exists('html_escape')) { + function html_escape($string) { + return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } +} + +if (!function_exists('__')) { + function __($text, $domain = '') { + return $text; + } +} + +if (!function_exists('__esc')) { + function __esc($text, $domain = '') { + return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } +} + +if (!function_exists('cacti_log')) { + function cacti_log($message, $also_print = false, $log_type = '', $level = 0) { + } +} + +if (!function_exists('cacti_sizeof')) { + function cacti_sizeof($array) { + return is_array($array) ? count($array) : 0; + } +} + +if (!function_exists('is_realm_allowed')) { + function is_realm_allowed($realm) { + return true; + } +} + +if (!function_exists('raise_message')) { + function raise_message($id, $text = '', $level = 0) { + } +} + +if (!function_exists('get_request_var')) { + function get_request_var($name) { + return ''; + } +} + +if (!function_exists('get_nfilter_request_var')) { + function get_nfilter_request_var($name) { + return ''; + } +} + +if (!function_exists('get_filter_request_var')) { + function get_filter_request_var($name) { + return ''; + } +} + +if (!function_exists('form_input_validate')) { + function form_input_validate($value, $name, $regex, $optional, $error) { + return $value; + } +} + +if (!function_exists('is_error_message')) { + function is_error_message() { + return false; + } +} + +if (!function_exists('sql_save')) { + function sql_save($array, $table, $key = 'id') { + return isset($array['id']) ? $array['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); +} From f1315c9edf59b998bf1e3c4e2b5f6f0a8fa5d234 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Thu, 9 Apr 2026 23:02:50 -0700 Subject: [PATCH 2/4] fix(tests): harden Pest infra - fail-fast on missing files, fix dependabot - Throw RuntimeException when realpath/file_get_contents fails (previously silent continue hid unscanned files) - Fix Dependabot ecosystem from npm to composer - Remove committed .omc session artifacts, add .omc/ to .gitignore Signed-off-by: Thomas Vincent --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ee3d28a..cb5fd3f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ tmp/* exports/* *.swp locales/po/*.mo +.omc/ From 522c61e2a26142409ac100fb7da0788953d5a55b Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 10 Apr 2026 07:01:07 -0700 Subject: [PATCH 3/4] style: remove trailing double semicolons Signed-off-by: Thomas Vincent --- lib/funct_shared.php | 2 +- poller_reportit.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/funct_shared.php b/lib/funct_shared.php index ab7a1b3..947aba2 100644 --- a/lib/funct_shared.php +++ b/lib/funct_shared.php @@ -755,7 +755,7 @@ function in_process($report_id, $status = 1) { db_execute_prepared('UPDATE plugin_reportit_reports SET state = ?, last_state = ? WHERE id = ?', - array($status, $now, $report_id));; + array($status, $now, $report_id)); } function stat_process($report_id) { diff --git a/poller_reportit.php b/poller_reportit.php index bef70bd..bbeec03 100644 --- a/poller_reportit.php +++ b/poller_reportit.php @@ -903,7 +903,7 @@ function autorrdlist($reportid) { FROM plugin_reportit_reports AS a INNER JOIN plugin_reportit_templates AS b ON a.template_id = b.id - WHERE a.id = ?', array($reportid));; + WHERE a.id = ?', array($reportid)); $sql_params = array(); From f562186a64166991df8b215d77b484d3bde4f36a Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Tue, 28 Jul 2026 20:30:38 -0700 Subject: [PATCH 4/4] fix(tests): harden Pest security suite and fix describe() incompatibility Fail fast on unreadable target files instead of silently skipping them, narrow the prepared-statement scan to already-migrated files, dedupe the PHP 7.4 compatibility file-reading logic, and replace describe() (not available in Pest v1) with top-level it() blocks so the suite actually executes. Signed-off-by: Thomas Vincent --- tests/Security/Php74CompatibilityTest.php | 144 +++++++----------- .../PreparedStatementConsistencyTest.php | 70 ++++----- tests/Security/SetupStructureTest.php | 42 ++--- 3 files changed, 116 insertions(+), 140 deletions(-) diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php index e1a5edd..d60bdf1 100644 --- a/tests/Security/Php74CompatibilityTest.php +++ b/tests/Security/Php74CompatibilityTest.php @@ -12,95 +12,69 @@ * Cacti 1.2.x plugins must remain compatible with PHP 7.4. */ -describe('PHP 7.4 compatibility in reportit', function () { - $files = array( - 'lib/funct_calculate.php', - 'lib/funct_export.php', - 'lib/funct_html.php', - 'lib/funct_online.php', - 'lib/funct_reports.php', - 'lib/funct_shared.php', - 'lib/funct_validate.php', - 'setup.php', - ); - - it('does not use str_contains (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - if ($path === false) { - continue; - } - - $contents = file_get_contents($path); - - if ($contents === false) { - continue; - } - - expect(preg_match('/\bstr_contains\s*\(/', $contents))->toBe(0, - "{$relativeFile} uses str_contains() which requires PHP 8.0" - ); - } - }); - - it('does not use str_starts_with (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - if ($path === false) { - continue; - } - - $contents = file_get_contents($path); - - if ($contents === false) { - continue; - } - - expect(preg_match('/\bstr_starts_with\s*\(/', $contents))->toBe(0, - "{$relativeFile} uses str_starts_with() which requires PHP 8.0" - ); - } - }); - - it('does not use str_ends_with (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - if ($path === false) { - continue; - } - - $contents = file_get_contents($path); - - if ($contents === false) { - continue; - } +$files = array( + 'lib/funct_calculate.php', + 'lib/funct_export.php', + 'lib/funct_html.php', + 'lib/funct_online.php', + 'lib/funct_reports.php', + 'lib/funct_shared.php', + 'lib/funct_validate.php', + 'setup.php', +); + +$readFileContents = function (string $relativeFile): string { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + if ($path === false) { + throw new RuntimeException("Failed to resolve path for compatibility check: {$relativeFile}"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Failed to read file for compatibility check: {$relativeFile}"); + } + + return $contents; +}; + +it('does not use str_contains (PHP 8.0)', function () use ($files, $readFileContents) { + foreach ($files as $relativeFile) { + $contents = $readFileContents($relativeFile); + + expect(preg_match('/\bstr_contains\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses str_contains() which requires PHP 8.0" + ); + } +}); - expect(preg_match('/\bstr_ends_with\s*\(/', $contents))->toBe(0, - "{$relativeFile} uses str_ends_with() which requires PHP 8.0" - ); - } - }); +it('does not use str_starts_with (PHP 8.0)', function () use ($files, $readFileContents) { + foreach ($files as $relativeFile) { + $contents = $readFileContents($relativeFile); - it('does not use nullsafe operator (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); + expect(preg_match('/\bstr_starts_with\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses str_starts_with() which requires PHP 8.0" + ); + } +}); - if ($path === false) { - continue; - } +it('does not use str_ends_with (PHP 8.0)', function () use ($files, $readFileContents) { + foreach ($files as $relativeFile) { + $contents = $readFileContents($relativeFile); - $contents = file_get_contents($path); + expect(preg_match('/\bstr_ends_with\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses str_ends_with() which requires PHP 8.0" + ); + } +}); - if ($contents === false) { - continue; - } +it('does not use nullsafe operator (PHP 8.0)', function () use ($files, $readFileContents) { + foreach ($files as $relativeFile) { + $contents = $readFileContents($relativeFile); - expect(preg_match('/\?->/', $contents))->toBe(0, - "{$relativeFile} uses nullsafe operator which requires PHP 8.0" - ); - } - }); + expect(preg_match('/\?->/', $contents))->toBe(0, + "{$relativeFile} uses nullsafe operator which requires PHP 8.0" + ); + } }); diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index fcdd2ab..2784f76 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -12,53 +12,47 @@ * Catches regressions where raw db_execute/db_fetch_* calls creep back in. */ -describe('prepared statement consistency in reportit', function () { - it('uses prepared DB helpers in all plugin files', function () { - $targetFiles = array( - 'lib/funct_calculate.php', - 'lib/funct_export.php', - 'lib/funct_html.php', - 'lib/funct_online.php', - 'lib/funct_reports.php', - 'lib/funct_shared.php', - 'lib/funct_validate.php', - 'setup.php', +it('uses prepared DB helpers in migrated plugin files', function () { + $targetFiles = array( + 'lib/funct_calculate.php', + 'lib/funct_export.php', + 'lib/funct_html.php', + 'lib/funct_validate.php', + ); + + $rawPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(/'; + $preparedPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)_prepared\s*\(/'; + + foreach ($targetFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + expect($path)->not->toBeFalse( + "Failed to resolve target file {$relativeFile}" ); - $rawPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(/'; - $preparedPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)_prepared\s*\(/'; + $contents = file_get_contents($path); - foreach ($targetFiles as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); + expect($contents)->not->toBeFalse( + "Failed to read target file {$relativeFile}" + ); - if ($path === false) { - continue; - } + $lines = explode("\n", $contents); + $rawCallsOutsideComments = 0; - $contents = file_get_contents($path); + foreach ($lines as $line) { + $trimmed = ltrim($line); - if ($contents === false) { + if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0 || strpos($trimmed, '#') === 0) { continue; } - $lines = explode("\n", $contents); - $rawCallsOutsideComments = 0; - - foreach ($lines as $line) { - $trimmed = ltrim($line); - - if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0 || strpos($trimmed, '#') === 0) { - continue; - } - - if (preg_match($rawPattern, $line) && !preg_match($preparedPattern, $line)) { - $rawCallsOutsideComments++; - } + if (preg_match($rawPattern, $line) && !preg_match($preparedPattern, $line)) { + $rawCallsOutsideComments++; } - - expect($rawCallsOutsideComments)->toBe(0, - "File {$relativeFile} contains raw (unprepared) DB calls" - ); } - }); + + expect($rawCallsOutsideComments)->toBe(0, + "File {$relativeFile} contains raw (unprepared) DB calls" + ); + } }); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php index 50b4313..b693d09 100644 --- a/tests/Security/SetupStructureTest.php +++ b/tests/Security/SetupStructureTest.php @@ -11,26 +11,34 @@ * Verify setup.php defines required plugin hooks and info function. */ -describe('reportit setup.php structure', function () { - $source = file_get_contents(realpath(__DIR__ . '/../../setup.php')); +$setupPath = realpath(__DIR__ . '/../../setup.php'); - it('defines plugin_reportit_install function', function () use ($source) { - expect($source)->toContain('function plugin_reportit_install'); - }); +if ($setupPath === false) { + throw new RuntimeException('Unable to resolve setup.php for structure tests.'); +} - it('defines plugin_reportit_version function', function () use ($source) { - expect($source)->toContain('function plugin_reportit_version'); - }); +$source = file_get_contents($setupPath); - it('defines plugin_reportit_uninstall function', function () use ($source) { - expect($source)->toContain('function plugin_reportit_uninstall'); - }); +if ($source === false) { + throw new RuntimeException('Unable to read setup.php for structure tests.'); +} - it('returns version array with name key', function () use ($source) { - expect($source)->toMatch('/[\'\""]name[\'\""]\s*=>/'); - }); +it('defines plugin_reportit_install function', function () use ($source) { + expect($source)->toContain('function plugin_reportit_install'); +}); + +it('defines plugin_reportit_version function', function () use ($source) { + expect($source)->toContain('function plugin_reportit_version'); +}); + +it('defines plugin_reportit_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_reportit_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('returns version array with version key', function () use ($source) { + expect($source)->toMatch('/[\'\""]version[\'\""]\s*=>/'); });