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 diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..675e214 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,12 @@ +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..38724ad --- /dev/null +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -0,0 +1,99 @@ +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 = [ + '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*\(/'; + + foreach ($targetFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + expect($path)->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..75e5883 --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,84 @@ +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'];"); +}); + +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*,/'); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..d0356e8 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,207 @@ + '/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'][] = ['fn' => 'db_execute', 'sql' => $sql, 'params' => []]; + + return true; + } +} + +if (!function_exists('db_execute_prepared')) { + 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 []; + } +} + +if (!function_exists('db_fetch_assoc_prepared')) { + function db_fetch_assoc_prepared($sql, $params = []) { + return []; + } +} + +if (!function_exists('db_fetch_row')) { + function db_fetch_row($sql) { + return []; + } +} + +if (!function_exists('db_fetch_row_prepared')) { + function db_fetch_row_prepared($sql, $params = []) { + return []; + } +} + +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 = []) { + 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); +}