From 83bf46eac0a414573aa55f9fcd13a9eb68a8749f Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 11 Apr 2026 16:01:44 -0700 Subject: [PATCH 1/7] test: add Pest v1 security test infrastructure --- tests/Pest.php | 14 ++ tests/Security/Php74CompatibilityTest.php | 77 +++++++ .../PreparedStatementConsistencyTest.php | 50 +++++ tests/Security/SetupStructureTest.php | 49 +++++ tests/bootstrap.php | 205 ++++++++++++++++++ 5 files changed, 395 insertions(+) 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/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..e2132a3 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,14 @@ +not->toBeFalse("Failed to resolve target file path: {$relativeFile}"); + + $contents = file_get_contents($path); + expect($contents)->not->toBeFalse("Failed to read target file: {$relativeFile}"); + + return $contents; +} + +it('does not use str_contains (PHP 8.0)', function () { + foreach (thold_security_compatibility_files() as $relativeFile) { + $contents = thold_security_read_file($relativeFile); + + 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 () { + foreach (thold_security_compatibility_files() as $relativeFile) { + $contents = thold_security_read_file($relativeFile); + + 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 () { + foreach (thold_security_compatibility_files() as $relativeFile) { + $contents = thold_security_read_file($relativeFile); + + 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 () { + foreach (thold_security_compatibility_files() as $relativeFile) { + $contents = thold_security_read_file($relativeFile); + + 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..441b8a0 --- /dev/null +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -0,0 +1,50 @@ +not->toBeFalse("Failed to resolve target file path: {$relativeFile}"); + + $contents = file_get_contents($path); + expect($contents)->not->toBeFalse("Failed to read target file: {$relativeFile}"); + + $lines = explode("\n", $contents); + + foreach ($lines as $lineNumber => $line) { + $trimmed = ltrim($line); + + if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0 || strpos($trimmed, '#') === 0) { + continue; + } + + $hasInterpolatedRawCall = preg_match($rawInterpolatedPattern, $line) === 1; + $hasPreparedCall = preg_match($preparedPattern, $line) === 1; + + expect($hasInterpolatedRawCall && !$hasPreparedCall)->toBeFalse( + sprintf('File %s contains an interpolated raw db_* call at line %d', $relativeFile, $lineNumber + 1) + ); + } + } +}); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php new file mode 100644 index 0000000..bd9eda4 --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,49 @@ +not->toBeFalse('Failed to resolve setup.php'); + expect(is_readable($setupPath))->toBeTrue('setup.php is not readable'); + + $source = file_get_contents($setupPath); + expect($source)->not->toBeFalse('Failed to read setup.php'); + + return $source; +} + +it('defines plugin_thold_install function', function () { + $source = thold_read_setup_source(); + expect($source)->toContain('function plugin_thold_install'); +}); + +it('defines plugin_thold_version function', function () { + $source = thold_read_setup_source(); + expect($source)->toContain('function plugin_thold_version'); +}); + +it('defines plugin_thold_uninstall function', function () { + $source = thold_read_setup_source(); + expect($source)->toContain('function plugin_thold_uninstall'); +}); + +it('returns version array with name key', function () { + $source = thold_read_setup_source(); + expect($source)->toMatch('/[\'\""]name[\'\""]\s*=>/'); +}); + +it('reads plugin metadata from INFO and returns the info section', function () { + $source = thold_read_setup_source(); + expect($source)->toContain('parse_ini_file'); + expect($source)->toContain("return \$info['info'];"); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..c7e55d3 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,205 @@ + '/var/www/html/cacti', + 'url_path' => '/cacti/', + 'cacti_version' => '1.2.999', +); + +if (!function_exists('db_execute')) { + function db_execute($sql) { + $GLOBALS['__test_db_calls'][] = array('fn' => '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 68de934715a0b381604059c0e7369d04d957b61f Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 17 Apr 2026 02:21:05 -0700 Subject: [PATCH 2/7] fix(ci): pass root password to MariaDB health check Signed-off-by: Thomas Vincent --- .github/workflows/plugin-ci-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 2280b99..014fa30 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -52,7 +52,7 @@ jobs: ports: - 3306:3306 options: >- - --health-cmd="mysqladmin ping" + --health-cmd="mysqladmin ping -u root -pcactiroot" --health-interval=10s --health-timeout=5s --health-retries=3 From bbc07c6c2e8111e0b0e1545f306f7300bc8d0398 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 17 Apr 2026 02:37:23 -0700 Subject: [PATCH 3/7] style: apply php-cs-fixer to tests/Pest.php --- tests/Pest.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/Pest.php b/tests/Pest.php index e2132a3..675e214 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -7,8 +7,6 @@ +-------------------------------------------------------------------------+ */ -/* - * Pest configuration file. - */ +// Pest configuration file. require_once __DIR__ . '/bootstrap.php'; From 693afccb04d6ccb779d4a350eaa1533e182b853d Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 17 Apr 2026 02:37:24 -0700 Subject: [PATCH 4/7] style: apply php-cs-fixer to tests/bootstrap.php --- tests/bootstrap.php | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c7e55d3..d0356e8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,48 +12,50 @@ * can be loaded in isolation without the full Cacti application. */ -$GLOBALS['__test_db_calls'] = array(); -$GLOBALS['config'] = array( +$GLOBALS['__test_db_calls'] = []; +$GLOBALS['config'] = [ 'base_path' => '/var/www/html/cacti', 'url_path' => '/cacti/', 'cacti_version' => '1.2.999', -); +]; if (!function_exists('db_execute')) { function db_execute($sql) { - $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute', 'sql' => $sql, 'params' => array()); + $GLOBALS['__test_db_calls'][] = ['fn' => 'db_execute', 'sql' => $sql, 'params' => []]; + 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); + function db_execute_prepared($sql, $params = []) { + $GLOBALS['__test_db_calls'][] = ['fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params]; + return true; } } if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { - return array(); + return []; } } if (!function_exists('db_fetch_assoc_prepared')) { - function db_fetch_assoc_prepared($sql, $params = array()) { - return array(); + function db_fetch_assoc_prepared($sql, $params = []) { + return []; } } if (!function_exists('db_fetch_row')) { function db_fetch_row($sql) { - return array(); + return []; } } if (!function_exists('db_fetch_row_prepared')) { - function db_fetch_row_prepared($sql, $params = array()) { - return array(); + function db_fetch_row_prepared($sql, $params = []) { + return []; } } @@ -64,7 +66,7 @@ function db_fetch_cell($sql) { } if (!function_exists('db_fetch_cell_prepared')) { - function db_fetch_cell_prepared($sql, $params = array()) { + function db_fetch_cell_prepared($sql, $params = []) { return ''; } } From 1e2da1c217ccc9c86f25d2e46ad12ab39e95c41e Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 17 Apr 2026 02:37:25 -0700 Subject: [PATCH 5/7] style: apply php-cs-fixer to tests/Security/PreparedStatementConsistencyTest.php --- .../PreparedStatementConsistencyTest.php | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index 441b8a0..38724ad 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -12,13 +12,62 @@ * obviously variable-interpolated SQL on a single line. */ +it('raw-interpolated pattern matches known unsafe db call strings', function () { + $pattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(\s*(["\']).*\$[A-Za-z_{]/'; + + $unsafe = [ + 'db_execute("SELECT * FROM thold WHERE id=$id")', + "db_fetch_row('SELECT id FROM thold WHERE name=$name')", + 'db_fetch_cell("SELECT value FROM thold WHERE host_id=$host_id")', + 'db_fetch_assoc("SELECT * FROM thold WHERE template_id={$template_id}")', + ]; + + foreach ($unsafe as $line) { + expect(preg_match($pattern, $line))->toBe(1, "Expected unsafe pattern match on: {$line}"); + } +}); + +it('raw-interpolated pattern does not match safe db call strings', function () { + $pattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(\s*(["\']).*\$[A-Za-z_{]/'; + + $safe = [ + 'db_execute_prepared("SELECT * FROM thold WHERE id = ?", array($id))', + 'db_fetch_row_prepared("SELECT id FROM thold WHERE name = ?", array($name))', + 'db_execute("SELECT * FROM thold WHERE id = ?")', + 'db_execute("SELECT COUNT(*) FROM thold")', + ]; + + foreach ($safe as $line) { + expect(preg_match($pattern, $line))->toBe(0, "Expected no pattern match on safe call: {$line}"); + } +}); + +it('comment-skip logic excludes full-line comments from interpolation checks', function () { + $commentLines = [ + '// db_execute("SELECT * FROM thold WHERE id=$id")', + ' // db_execute("SELECT * FROM thold WHERE id=$id")', + ' * db_execute("SELECT * FROM thold WHERE id=$id")', + ' * example db_fetch_row("SELECT id FROM thold WHERE host=$host")', + '# db_execute("SELECT * FROM thold WHERE id=$id")', + ]; + + foreach ($commentLines as $line) { + $trimmed = ltrim($line); + $isSkipped = strpos($trimmed, '//') === 0 + || strpos($trimmed, '*') === 0 + || strpos($trimmed, '#') === 0; + + expect($isSkipped)->toBeTrue("Full-line comment should be skipped: {$line}"); + } +}); + it('does not introduce single-line interpolated db_* calls in hardened files', function () { - $targetFiles = array( + $targetFiles = [ 'poller_thold.php', 'setup.php', 'thold.php', 'thold_graph.php', - ); + ]; $rawInterpolatedPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(\s*(["\']).*\$[A-Za-z_{]/'; $preparedPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)_prepared\s*\(/'; @@ -40,7 +89,7 @@ } $hasInterpolatedRawCall = preg_match($rawInterpolatedPattern, $line) === 1; - $hasPreparedCall = preg_match($preparedPattern, $line) === 1; + $hasPreparedCall = preg_match($preparedPattern, $line) === 1; expect($hasInterpolatedRawCall && !$hasPreparedCall)->toBeFalse( sprintf('File %s contains an interpolated raw db_* call at line %d', $relativeFile, $lineNumber + 1) From 4191256745621285838fa968a14aac99ae5c03a4 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 17 Apr 2026 02:37:26 -0700 Subject: [PATCH 6/7] style: apply php-cs-fixer to tests/Security/Php74CompatibilityTest.php --- tests/Security/Php74CompatibilityTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php index ce53f1a..edf3da3 100644 --- a/tests/Security/Php74CompatibilityTest.php +++ b/tests/Security/Php74CompatibilityTest.php @@ -13,7 +13,7 @@ */ function thold_security_compatibility_files() { - return array( + return [ 'includes/database.php', 'includes/polling.php', 'includes/settings.php', @@ -23,7 +23,7 @@ function thold_security_compatibility_files() { 'setup.php', 'thold.php', 'thold_graph.php', - ); + ]; } function thold_security_read_file($relativeFile) { From 0df55d017c419cdf72be134a53721c5c3a2f884b Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 17 Apr 2026 02:37:27 -0700 Subject: [PATCH 7/7] style: apply php-cs-fixer to tests/Security/SetupStructureTest.php --- tests/Security/SetupStructureTest.php | 41 +++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php index bd9eda4..75e5883 100644 --- a/tests/Security/SetupStructureTest.php +++ b/tests/Security/SetupStructureTest.php @@ -7,9 +7,7 @@ +-------------------------------------------------------------------------+ */ -/* - * Verify setup.php defines required plugin hooks and info function. - */ +// Verify setup.php defines required plugin hooks and info function. function thold_read_setup_source() { $setupPath = realpath(__DIR__ . '/../../setup.php'); @@ -47,3 +45,40 @@ function thold_read_setup_source() { expect($source)->toContain('parse_ini_file'); expect($source)->toContain("return \$info['info'];"); }); + +it('setup.php file exists and is readable at expected path', function () { + $setupPath = __DIR__ . '/../../setup.php'; + expect(file_exists($setupPath))->toBeTrue('setup.php does not exist at expected location'); + expect(is_readable($setupPath))->toBeTrue('setup.php exists but is not readable'); + expect(realpath($setupPath))->not->toBeFalse('realpath() failed to resolve setup.php'); +}); + +it('registers required poller and device hooks via api_plugin_register_hook', function () { + $source = thold_read_setup_source(); + expect($source)->toContain("'poller_output'"); + expect($source)->toContain("'poller_bottom'"); + expect($source)->toContain("'device_action_array'"); + expect($source)->toContain("'api_device_save'"); +}); + +it('api_plugin_register_hook calls use at least four arguments', function () { + $source = thold_read_setup_source(); + $matches = []; + preg_match_all('/api_plugin_register_hook\s*\([^;)]+\)/', $source, $matches); + + expect(count($matches[0]))->toBeGreaterThan(0, 'No api_plugin_register_hook calls found in setup.php'); + + foreach ($matches[0] as $call) { + $argCount = substr_count($call, ',') + 1; + expect($argCount)->toBeGreaterThanOrEqual( + 4, + "Hook call has fewer than 4 arguments: {$call}" + ); + } +}); + +it('api_plugin_register_hook hook names are non-empty string literals', function () { + $source = thold_read_setup_source(); + expect($source)->not->toMatch("/api_plugin_register_hook\s*\(\s*[^,]+,\s*''\s*,/"); + expect($source)->not->toMatch('/api_plugin_register_hook\s*\(\s*[^,]+,\s*""\s*,/'); +});