diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6578aab..839b468 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -25,20 +25,19 @@ When generating code for this repository: ## Project Structure ``` -wmi/ # Repository root (install to plugins/wmi/ in Cacti) -├── script/ # External wmic/collection helper scripts -├── templates/ # Data query/graph template XML -├── tests/ # Test suite -├── functions.php # Core hook callbacks and shared logic -├── linux_wmi.php # Linux (`wmic`) collection path -├── poller_wmi.php # Background poller entry point (CLI) -├── wmi_accounts.php # WMI credential administration -├── wmi_queries.php # WQL query administration -├── wmi_script.php # Ad hoc query/tool runner -├── wmi_tools.php # Diagnostic tools UI -├── INFO # Plugin metadata (name, version, compat) +wmi/ # Repository root (install to plugins/wmi/ in Cacti) +├── script/ # External wmic/collection helper scripts +├── templates/ # Data query/graph template XML +├── tests/ # Test suite +├── includes/ # functions.php (core hook callbacks/logic), database.php (schema), linux_wmi.php (wmic/CIM client) +├── poller_wmi.php # Background poller entry point (CLI) +├── wmi_accounts.php # WMI credential administration +├── wmi_queries.php # WQL query administration +├── wmi_script.php # Ad hoc query/tool runner +├── wmi_tools.php # Diagnostic tools UI +├── INFO # Plugin metadata (name, version, compat) ├── README.md -└── setup.php # Plugin install/uninstall/upgrade hooks +└── setup.php # Plugin install/uninstall/upgrade hooks ``` ## Naming Conventions @@ -194,3 +193,7 @@ existing code or adding new code, not just in dedicated cleanup passes: line, `@param` lines, a blank comment line, then `@return`. Infer parameter/return types from actual usage; don't change the function's real type-hints in the same pass (let static analysis flag mismatches separately). Skip vendored third-party library files. + +## File manifest & upgrade pruning + +The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `wmi_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 61b52b4..85484ca 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -88,6 +88,9 @@ jobs: - name: Check PHP version run: php -v + - name: Validate plugin manifest (expected-file drift) + run: php cacti/plugins/wmi/tests/bin/validate-manifest.php + - name: Run apt-get update run: sudo apt-get update diff --git a/includes/database.php b/includes/database.php new file mode 100644 index 0000000..a63d72c --- /dev/null +++ b/includes/database.php @@ -0,0 +1,176 @@ + 'wmi_account', + 'type' => 'int(10)', + 'unsigned' => true, + 'NULL' => false, + 'default' => '0', + 'after' => 'disabled' + ] + ); + + db_execute("CREATE TABLE IF NOT EXISTS `wmi_user_accounts` ( + `id` int(11) UNSIGNED NOT NULL auto_increment, + `name` varchar(64) NOT NULL, + `username` varchar(64) NOT NULL, + `password` varchar(256) NOT NULL, + PRIMARY KEY (`id`)) + ENGINE=InnoDB + COMMENT='Holds Account Information for WMI Queries'"); + + db_execute("CREATE TABLE IF NOT EXISTS `wmi_wql_queries` ( + `id` int(11) UNSIGNED NOT NULL auto_increment, + `hash` varchar(32) NOT NULL default '', + `name` varchar(64) NOT NULL, + `frequency` mediumint(8) unsigned NOT NULL DEFAULT '86400', + `enabled` char(2) DEFAULT 'on', + `namespace` varchar(64) NOT NULL, + `query` varchar(1024) NOT NULL, + `primary_key` varchar(128) NOT NULL DEFAULT 'None', + PRIMARY KEY (`id`)) + ENGINE=InnoDB + COMMENT='Holds WMI Queries for Devices'"); + + db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_query` ( + `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `sort_field` varchar(50) NOT NULL DEFAULT '', + `title_format` varchar(50) NOT NULL DEFAULT '', + `last_started` timestamp NOT NULL DEFAULT '0000-00-00', + `last_runtime` double NOT NULL DEFAULT '0.00', + `last_failed` timestamp NOT NULL DEFAULT '0000-00-00', + PRIMARY KEY (`host_id`,`wmi_query_id`)) + ENGINE=InnoDB + COMMENT='Holds WMI Data Queries'"); + + db_execute("CREATE TABLE IF NOT EXISTS `host_template_wmi_query` ( + `host_template_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`host_template_id`,`wmi_query_id`)) + ENGINE=InnoDB + COMMENT='Holds Device Template WMI Queries'"); + + db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_accounts` ( + `id` int(11) UNSIGNED NOT NULL auto_increment, + `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `account_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `host_id` (`host_id`)) + ENGINE=InnoDB + COMMENT='Holds Device WMI Accounts'"); + + db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_cache` ( + `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `field_name` varchar(50) NOT NULL DEFAULT '', + `field_value` varchar(4096) DEFAULT NULL, + `wmi_index` varchar(255) NOT NULL DEFAULT '', + `present` tinyint(4) NOT NULL DEFAULT '1', + `last_updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`host_id`,`wmi_query_id`,`field_name`,`wmi_index`), + KEY `host_id` (`host_id`,`field_name`), + KEY `wmi_index` (`wmi_index`), + KEY `field_name` (`field_name`), + KEY `field_value` (`field_value`), + KEY `wim_query_id` (`wmi_query_id`), + KEY `present` (`present`), + KEY `last_updated` (`last_updated`)) + ENGINE=InnoDB + COMMENT='Holds Device WMI Information'"); + + db_execute("CREATE TABLE IF NOT EXISTS `wmi_processes` ( + `pid` int(10) unsigned NOT NULL, + `taskid` int(10) unsigned NOT NULL, + `started` timestamp NOT NULL default CURRENT_TIMESTAMP, + PRIMARY KEY (`pid`)) + ENGINE=MEMORY + COMMENT='Running wmi collector processes';"); + + $exists = db_fetch_cell('SELECT id FROM data_input WHERE hash="4af550dfe8b451579054d038ad62ba3e"'); + + if (!$exists) { + $save = []; + $save['hash'] = '4af550dfe8b451579054d038ad62ba3e'; + $save['name'] = 'Get WMI Data'; + $save['input_string'] = ''; + $save['type_id'] = 7; + $id = sql_save($save, 'data_input'); + + if ($id) { + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('e45cfa73589b88887725350a728d2ee9',$id,'The WMI Class Name','class','in','',0,'','','')"); + + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('c5f782d783edec607f64bea9cccd533c',$id,'The WMI Column Name','column','in','',0,'','','')"); + } + } + + $exists = db_fetch_cell('SELECT id + FROM data_input + WHERE hash="42e584b81075f6ad6556e62afc509179"'); + + if (!$exists) { + $save = []; + $save['hash'] = '42e584b81075f6ad6556e62afc509179'; + $save['name'] = 'Get WMI Data (Indexed)'; + $save['input_string'] = ''; + $save['type_id'] = 8; + $id = sql_save($save, 'data_input'); + + if ($id) { + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('fb6317f2c49e494007e968283576d5a8',$id,'The WMI Class Name','class','in','',0,'','','')"); + + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('cfebf9aa08f98bc1bfda7de2ebe12d94',$id,'The WMI Column Name','column','in','',0,'','','')"); + + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('41798400f48141c25bc2407b5f5b1573',$id,'Output Type ID','output_type','in','',0,'output_type','','')"); + + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('02cd18a75a17e0a7d4ca28bc224630e0',$id,'Output Value','output','out','on',0,'','','')"); + } + } +} diff --git a/functions.php b/includes/functions.php similarity index 99% rename from functions.php rename to includes/functions.php index cfc8add..e936993 100644 --- a/functions.php +++ b/includes/functions.php @@ -22,6 +22,8 @@ +-------------------------------------------------------------------------+ */ +require_once(__DIR__ . '/linux_wmi.php'); + /** * Renders this plugin's tabbed navigation (Queries, Authentication) atop * wmi_queries.php/wmi_accounts.php, highlighting the currently active tab @@ -112,7 +114,7 @@ function plugin_wmi_query_exists($query) { function plugin_wmi_create_dataquery_xml($id) { global $config; - include_once($config['base_path'] . '/lib/export.php'); + require_once($config['base_path'] . '/lib/export.php'); $wmic = db_fetch_row_prepared('SELECT * FROM wmi_wql_queries WHERE id = ?', [$id]); $data = ''; @@ -445,8 +447,6 @@ function run_store_wmi_query($host_id, $wmi_query_id) { // Cacti server and the wmic shell transport otherwise (see // Linux_WMI::default_transport()), so the same client is used here // regardless of $config['cacti_server_os']. - include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); - $wmi = new Linux_WMI(); $wmi->hostname = $host; $wmi->username = $username; diff --git a/linux_wmi.php b/includes/linux_wmi.php similarity index 100% rename from linux_wmi.php rename to includes/linux_wmi.php diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 869e001..c26374d 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,6 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-09-28 16:09-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -17,11 +16,11 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: functions.php wmi_queries.php wmi_tools.php +#: includes/functions.php wmi_queries.php wmi_tools.php msgid "Queries" msgstr "" -#: functions.php +#: includes/functions.php msgid "Authentication" msgstr "" diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c1af301 --- /dev/null +++ b/manifest.json @@ -0,0 +1,25 @@ +{ + "tombstones": [ + "functions.php", + "linux_wmi.php" + ], + "expected": [ + "CHANGELOG.md", + "INFO", + "LICENSE", + "README.md", + "includes/", + "index.php", + "locales/", + "manifest.json", + "poller_wmi.php", + "script/", + "setup.php", + "templates/", + "wmi_accounts.php", + "wmi_queries.php", + "wmi_script.php", + "wmi_tools.php" + ], + "whitelist": [] +} diff --git a/phpunit.xml b/phpunit.xml index b3b4c5f..20406df 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -31,14 +31,8 @@ setup.php - functions.php - linux_wmi.php - poller_wmi.php - wmi_accounts.php - wmi_queries.php - wmi_script.php - wmi_tools.php - script/wmi-script.php + includes/functions.php + includes/linux_wmi.php diff --git a/poller_wmi.php b/poller_wmi.php index b0de110..98706d0 100644 --- a/poller_wmi.php +++ b/poller_wmi.php @@ -26,10 +26,10 @@ chdir(__DIR__); chdir('../..'); -include('./include/cli_check.php'); -include_once('./lib/poller.php'); -include_once('./lib/ping.php'); -include_once('./plugins/wmi/functions.php'); +require('./include/cli_check.php'); +require_once('./lib/poller.php'); +require_once('./lib/ping.php'); +require_once('./plugins/wmi/includes/functions.php'); // process calling arguments $parms = $_SERVER['argv']; @@ -469,7 +469,7 @@ function display_version() { global $config; if (!function_exists('plugin_wmi_version')) { - include_once($config['base_path'] . '/plugins/wmi/setup.php'); + require_once($config['base_path'] . '/plugins/wmi/setup.php'); } $info = plugin_wmi_version(); diff --git a/script/wmi-script.php b/script/wmi-script.php index ad227ba..91152df 100644 --- a/script/wmi-script.php +++ b/script/wmi-script.php @@ -23,7 +23,7 @@ */ if (!isset($called_by_script_server)) { - include_once(__DIR__ . '/../include/cli_check.php'); + require_once(__DIR__ . '/../include/cli_check.php'); array_shift($_SERVER['argv']); if (isset($_SERVER['argv'][0]) && $_SERVER['argv'][0] == 'wmi_script') { @@ -66,7 +66,7 @@ function wmi_script($hostname, $host_id, $wmiquery, $cmd = '', $arg1 = '', $arg2 = '') { global $config; - include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/includes/linux_wmi.php'); $wmi = new Linux_WMI($host_id); $wmi->hostname = $hostname; diff --git a/setup.php b/setup.php index c4fe229..59006bd 100644 --- a/setup.php +++ b/setup.php @@ -51,6 +51,10 @@ function plugin_wmi_csp_nonce(): string { * @return void */ function plugin_wmi_install() { + global $config; + + require_once($config['base_path'] . '/plugins/wmi/includes/database.php'); + api_plugin_register_hook('wmi', 'config_arrays', 'wmi_config_arrays', 'setup.php'); api_plugin_register_hook('wmi', 'config_form', 'wmi_config_form', 'setup.php'); api_plugin_register_hook('wmi', 'config_settings', 'wmi_config_settings', 'setup.php'); @@ -158,161 +162,11 @@ function plugin_wmi_check_config() { * @return bool Always returns true. */ function plugin_wmi_upgrade() { + wmi_prune_files(); + return true; } -/** - * Adds the 'wmi_account' column to Cacti's host table, creates this - * plugin's database tables (wmi_user_accounts, wmi_wql_queries, - * host_wmi_query, host_template_wmi_query, host_wmi_accounts, - * host_wmi_cache, wmi_processes), and registers this plugin's two Data - * Input Methods ('Get WMI Data' and 'Get WMI Data (Indexed)') along with - * their input/output fields, if not already present. Called from - * plugin_wmi_install() during plugin installation. - * - * @return void - */ -function plugin_wmi_setup_tables() { - api_plugin_db_add_column('wmi', 'host', - [ - 'name' => 'wmi_account', - 'type' => 'int(10)', - 'unsigned' => true, - 'NULL' => false, - 'default' => '0', - 'after' => 'disabled' - ] - ); - - db_execute("CREATE TABLE IF NOT EXISTS `wmi_user_accounts` ( - `id` int(11) UNSIGNED NOT NULL auto_increment, - `name` varchar(64) NOT NULL, - `username` varchar(64) NOT NULL, - `password` varchar(256) NOT NULL, - PRIMARY KEY (`id`)) - ENGINE=InnoDB - COMMENT='Holds Account Information for WMI Queries'"); - - db_execute("CREATE TABLE IF NOT EXISTS `wmi_wql_queries` ( - `id` int(11) UNSIGNED NOT NULL auto_increment, - `hash` varchar(32) NOT NULL default '', - `name` varchar(64) NOT NULL, - `frequency` mediumint(8) unsigned NOT NULL DEFAULT '86400', - `enabled` char(2) DEFAULT 'on', - `namespace` varchar(64) NOT NULL, - `query` varchar(1024) NOT NULL, - `primary_key` varchar(128) NOT NULL DEFAULT 'None', - PRIMARY KEY (`id`)) - ENGINE=InnoDB - COMMENT='Holds WMI Queries for Devices'"); - - db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_query` ( - `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `sort_field` varchar(50) NOT NULL DEFAULT '', - `title_format` varchar(50) NOT NULL DEFAULT '', - `last_started` timestamp NOT NULL DEFAULT '0000-00-00', - `last_runtime` double NOT NULL DEFAULT '0.00', - `last_failed` timestamp NOT NULL DEFAULT '0000-00-00', - PRIMARY KEY (`host_id`,`wmi_query_id`)) - ENGINE=InnoDB - COMMENT='Holds WMI Data Queries'"); - - db_execute("CREATE TABLE IF NOT EXISTS `host_template_wmi_query` ( - `host_template_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - PRIMARY KEY (`host_template_id`,`wmi_query_id`)) - ENGINE=InnoDB - COMMENT='Holds Device Template WMI Queries'"); - - db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_accounts` ( - `id` int(11) UNSIGNED NOT NULL auto_increment, - `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `account_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - PRIMARY KEY (`id`), - KEY `host_id` (`host_id`)) - ENGINE=InnoDB - COMMENT='Holds Device WMI Accounts'"); - - db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_cache` ( - `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `field_name` varchar(50) NOT NULL DEFAULT '', - `field_value` varchar(4096) DEFAULT NULL, - `wmi_index` varchar(255) NOT NULL DEFAULT '', - `present` tinyint(4) NOT NULL DEFAULT '1', - `last_updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`host_id`,`wmi_query_id`,`field_name`,`wmi_index`), - KEY `host_id` (`host_id`,`field_name`), - KEY `wmi_index` (`wmi_index`), - KEY `field_name` (`field_name`), - KEY `field_value` (`field_value`), - KEY `wim_query_id` (`wmi_query_id`), - KEY `present` (`present`), - KEY `last_updated` (`last_updated`)) - ENGINE=InnoDB - COMMENT='Holds Device WMI Information'"); - - db_execute("CREATE TABLE IF NOT EXISTS `wmi_processes` ( - `pid` int(10) unsigned NOT NULL, - `taskid` int(10) unsigned NOT NULL, - `started` timestamp NOT NULL default CURRENT_TIMESTAMP, - PRIMARY KEY (`pid`)) - ENGINE=MEMORY - COMMENT='Running wmi collector processes';"); - - $exists = db_fetch_cell('SELECT id FROM data_input WHERE hash="4af550dfe8b451579054d038ad62ba3e"'); - - if (!$exists) { - $save = []; - $save['hash'] = '4af550dfe8b451579054d038ad62ba3e'; - $save['name'] = 'Get WMI Data'; - $save['input_string'] = ''; - $save['type_id'] = 7; - $id = sql_save($save, 'data_input'); - - if ($id) { - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('e45cfa73589b88887725350a728d2ee9',$id,'The WMI Class Name','class','in','',0,'','','')"); - - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('c5f782d783edec607f64bea9cccd533c',$id,'The WMI Column Name','column','in','',0,'','','')"); - } - } - - $exists = db_fetch_cell('SELECT id - FROM data_input - WHERE hash="42e584b81075f6ad6556e62afc509179"'); - - if (!$exists) { - $save = []; - $save['hash'] = '42e584b81075f6ad6556e62afc509179'; - $save['name'] = 'Get WMI Data (Indexed)'; - $save['input_string'] = ''; - $save['type_id'] = 8; - $id = sql_save($save, 'data_input'); - - if ($id) { - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('fb6317f2c49e494007e968283576d5a8',$id,'The WMI Class Name','class','in','',0,'','','')"); - - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('cfebf9aa08f98bc1bfda7de2ebe12d94',$id,'The WMI Column Name','column','in','',0,'','','')"); - - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('41798400f48141c25bc2407b5f5b1573',$id,'Output Type ID','output_type','in','',0,'output_type','','')"); - - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('02cd18a75a17e0a7d4ca28bc224630e0',$id,'Output Value','output','out','on',0,'','','')"); - } - } -} /** * Reads this plugin's INFO file and returns its [info] section. Used by @@ -965,7 +819,7 @@ function wmi_device_template_top() { function wmi_api_device_new($save) { global $config; - include_once($config['base_path'] . '/plugins/wmi/functions.php'); + require_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); if (read_config_option('wmi_autocreate') == 'on') { if (!empty($save['id'])) { @@ -975,3 +829,173 @@ function wmi_api_device_new($save) { return $save; } + +/** + * Removes files and directories that a previous version of this plugin + * shipped but that have since moved or been deleted, using the tombstone + * and whitelist lists in manifest.json. Whitelisted (user-data) paths and + * any VCS metadata (.git*) are never touched; the dev-only tests/ tree is + * removed. Any path that resolves outside the plugin directory (a tampered + * manifest.json) is refused, and any file/directory that cannot be removed + * (e.g. read-only) is reported to the Cacti log. Any top-level entry that is + * neither expected nor a tombstone nor whitelisted is logged to the Cacti + * log and left in place. Called on a plugin version change. + * + * @return void + * + * @global array $config Cacti global configuration array; used to resolve + * the plugin directory. + */ +function wmi_prune_files(): void { + global $config; + + $plugin_dir = $config['base_path'] . '/plugins/wmi'; + $manifest_path = $plugin_dir . '/manifest.json'; + + if (!is_readable($manifest_path)) { + return; + } + + $manifest = json_decode((string) file_get_contents($manifest_path), true); + + if (!is_array($manifest)) { + cacti_log('WARNING: wmi manifest.json could not be parsed; skipping file prune', false, 'WMI'); + + return; + } + + $tombstones = isset($manifest['tombstones']) && is_array($manifest['tombstones']) ? $manifest['tombstones'] : []; + $expected = isset($manifest['expected']) && is_array($manifest['expected']) ? $manifest['expected'] : []; + $whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : []; + + $protected = function (string $rel) use ($whitelist): bool { + if (strncmp($rel, '.git', 4) === 0 || strncmp($rel, '.md', 3) === 0) { + return true; + } + + foreach ($whitelist as $entry) { + $entry = trim((string) $entry, '/'); + + if ($entry !== '' && ($rel === $entry + || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0 + || strncmp($entry, $rel . '/', strlen($rel) + 1) === 0)) { + return true; + } + } + + return false; + }; + + // Security: resolve the plugin directory so a tampered manifest.json + // cannot steer the prune outside of it. + $plugin_real = realpath($plugin_dir); + + // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ + // tree and the phpunit.xml test configuration. + $remove = $tombstones; + $remove[] = 'tests/'; + $remove[] = 'phpunit.xml'; + + foreach ($remove as $rel) { + $rel = trim((string) $rel, '/'); + + if ($rel === '' || $protected($rel)) { + continue; + } + + // A tombstone must never contain '.'/'..' segments; a tampered manifest + // could use them to escape the plugin directory or target its root. + $segments = explode('/', $rel); + + if (in_array('.', $segments, true) || in_array('..', $segments, true)) { + cacti_log(sprintf('WARNING: wmi prune refused to remove %s: path contains a traversal segment (tampered manifest.json?)', $rel), false, 'WMI'); + + continue; + } + + $path = $plugin_dir . '/' . $rel; + + if (!is_link($path) && !file_exists($path)) { + continue; + } + + // Refuse any path that, after resolving symlinks and ../ segments, + // escapes the plugin directory (protects user data from a tampered + // manifest.json). + $anchor = is_link($path) ? dirname($path) : $path; + $real = realpath($anchor); + + if ($real === false || ($real !== $plugin_real && strncmp($real, $plugin_real . DIRECTORY_SEPARATOR, strlen((string) $plugin_real) + 1) !== 0)) { + cacti_log(sprintf('WARNING: wmi prune refused to remove %s: path resolves outside the plugin directory (tampered manifest.json?)', $rel), false, 'WMI'); + + continue; + } + + if (is_dir($path) && !is_link($path)) { + $removed = wmi_rmtree($path); + } else { + $removed = @unlink($path); + } + + if (!$removed) { + cacti_log(sprintf('WARNING: wmi upgrade could not remove %s (check file/directory permissions)', $rel), false, 'WMI'); + } + } + + // Surface any top-level entry the manifest does not account for. + $known = []; + + foreach (array_merge($expected, $tombstones) as $entry) { + $top = explode('/', trim((string) $entry, '/'))[0]; + + if ($top !== '') { + $known[$top] = true; + } + } + + $entries = scandir($plugin_dir); + + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml' || $protected($entry) || isset($known[$entry])) { + continue; + } + + cacti_log(sprintf('WARNING: wmi upgrade found a file/directory not described in manifest.json: %s (left in place)', $entry), false, 'WMI'); + } +} + +/** + * Recursively deletes a directory and its contents. Symlinks are removed + * without being followed. Helper for wmi_prune_files(). + * + * @param string $dir Absolute path to the directory to remove. + * + * @return bool True if the directory and everything under it was removed; + * false if any entry could not be deleted. + */ +function wmi_rmtree(string $dir): bool { + $entries = scandir($dir); + $ok = true; + + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $dir . '/' . $entry; + + if (is_dir($path) && !is_link($path)) { + if (!wmi_rmtree($path)) { + $ok = false; + } + } elseif (!@unlink($path)) { + $ok = false; + } + } + + if (!@rmdir($dir)) { + $ok = false; + } + + return $ok; +} diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index f1913c7..bb02df1 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -78,7 +78,8 @@ function wmi_test_extract_call_arguments($contents, $openParenPos) { describe('prepared statement consistency in wmi', function () { it('uses prepared DB helpers for every variable-bearing query', function () { $targetFiles = array( - 'functions.php', + 'includes/database.php', + 'includes/functions.php', 'poller_wmi.php', 'setup.php', 'wmi_accounts.php', @@ -89,13 +90,15 @@ function wmi_test_extract_call_arguments($contents, $openParenPos) { ); $allowedRawCalls = array( - 'functions.php' => array( + 'includes/database.php' => array( + 'data_input_fields', // install only; $id is sql_save()'s own just-inserted autoincrement id + ), + 'includes/functions.php' => array( "implode(', ', \$part)", // batch insert; every value already escaped via db_qstr() above ), 'setup.php' => array( "IN(' . \$id . ')", // uninstall only; $id is a GROUP_CONCAT of this plugin's own data_input ids 'array_to_sql_or($data_sources', // uninstall only; ids come from the query above, not request input - 'data_input_fields', // install only; $id is sql_save()'s own just-inserted autoincrement id ), 'wmi_accounts.php' => array( '$sql_where', // built via db_qstr(); rows/page filters are FILTER_VALIDATE_INT diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php new file mode 100644 index 0000000..7c15744 --- /dev/null +++ b/tests/Unit/PruneFilesTest.php @@ -0,0 +1,252 @@ + ['include/', 'oldfile.php', 'userdata/', 'gone.png'], + 'expected' => ['INFO', 'setup.php', 'includes/', 'manifest.json'], + 'whitelist' => ['userdata/'], + ]; + + $base = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + wmi_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // Tombstone and the dev-only tests/ tree are gone. + expect(is_dir($plugin . '/include'))->toBeFalse(); + expect(is_dir($plugin . '/tests'))->toBeFalse(); + expect(is_file($plugin . '/oldfile.php'))->toBeFalse(); + expect(is_file($plugin . '/phpunit.xml'))->toBeFalse(); + + // Whitelisted user data, VCS metadata, and expected files are untouched. + // (userdata/ is even listed as a tombstone, but the whitelist wins.) + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); + expect(is_dir($plugin . '/.git'))->toBeTrue(); + expect(is_file($plugin . '/INFO'))->toBeTrue(); + expect(is_dir($plugin . '/includes'))->toBeTrue(); + expect(is_file($plugin . '/.mdlrc'))->toBeTrue(); + expect(is_file($plugin . '/.md_style.rb'))->toBeTrue(); + + // An unexpected, non-whitelisted stray is left in place but logged. + expect(is_file($plugin . '/stray.php'))->toBeTrue(); + + $logged = implode("\n", $GLOBALS['__test_cacti_log']); + expect($logged)->toContain('stray.php'); + expect($logged)->not->toContain('userdata'); + expect($logged)->not->toContain('.git'); + expect($logged)->not->toContain('.mdlrc'); + expect($logged)->not->toContain('.md_style.rb'); +}); + +it('is a safe no-op when the manifest is missing', function () { + $base = sys_get_temp_dir() . '/wmi-prune-missing-' . uniqid(); + mkdir($base . '/plugins/wmi', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + wmi_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + expect($GLOBALS['__test_cacti_log'])->toBe([]); +}); + +it('logs and skips pruning when the manifest is malformed', function () { + $base = sys_get_temp_dir() . '/wmi-prune-bad-' . uniqid(); + $plugin = $base . '/plugins/wmi'; + mkdir($plugin, 0777, true); + file_put_contents($plugin . '/manifest.json', 'not json'); + mkdir($plugin . '/tests', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + wmi_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A malformed manifest must not delete anything. + expect(is_dir($plugin . '/tests'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not be parsed'); +}); + +it('refuses to remove a tombstone that resolves outside the plugin directory', function () { + $manifest = [ + 'tombstones' => ['../escapee.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + $outside = $base . '/plugins/escapee.txt'; + file_put_contents($outside, 'precious user data'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + wmi_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file is untouched and the refusal is logged. + expect(is_file($outside))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('a traversal segment'); +}); + +it('warns when a tombstoned path cannot be removed', function () { + $manifest = [ + 'tombstones' => ['locked/'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + mkdir($plugin . '/locked/sub', 0777, true); + file_put_contents($plugin . '/locked/sub/data', 'x'); + chmod($plugin . '/locked/sub', 0500); // read-only dir: its child cannot be unlinked + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + wmi_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + @chmod($plugin . '/locked/sub', 0700); + } + + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not remove'); +})->skip(function () { + return function_exists('posix_getuid') && posix_getuid() === 0; +}, 'permission checks are bypassed for the root user'); + +it('refuses a tombstone that escapes through a symlinked directory', function () { + $manifest = [ + 'tombstones' => ['escdir/secret.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + $outside = $base . '/outside'; + mkdir($outside, 0777, true); + file_put_contents($outside . '/secret.txt', 'precious user data'); + @symlink($outside, $plugin . '/escdir'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + wmi_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file reached through the symlink is untouched and logged. + expect(is_file($outside . '/secret.txt'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('outside the plugin directory'); +})->skip(function () { + $probe = sys_get_temp_dir() . '/.prune-symlink-probe-' . uniqid(); + $ok = @symlink(__FILE__, $probe); + @unlink($probe); + + return $ok === false; +}, 'symlinks are not supported on this filesystem'); + +it('protects a whitelisted file from a tombstone on its parent directory', function () { + $manifest = [ + 'tombstones' => ['userdata/'], + 'expected' => ['manifest.json'], + 'whitelist' => ['userdata/keep.dat'], + ]; + + $base = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + wmi_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A whitelisted file shields its parent directory from a tombstone. + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); +}); diff --git a/tests/Unit/WmiClientAutoloadTest.php b/tests/Unit/WmiClientAutoloadTest.php new file mode 100644 index 0000000..a4678ba --- /dev/null +++ b/tests/Unit/WmiClientAutoloadTest.php @@ -0,0 +1,17 @@ +toBeTrue(); +}); diff --git a/tests/Unit/WmiLifecycleTest.php b/tests/Unit/WmiLifecycleTest.php index 2db15eb..2866ee0 100644 --- a/tests/Unit/WmiLifecycleTest.php +++ b/tests/Unit/WmiLifecycleTest.php @@ -17,6 +17,7 @@ */ beforeAll(function () { + require_once __DIR__ . '/../../includes/database.php'; require_once __DIR__ . '/../../setup.php'; }); @@ -40,7 +41,16 @@ }); it('reports the upgrade as always successful', function () { - expect(plugin_wmi_upgrade())->toBeTrue(); + // Sandbox base_path so the upgrade-time prune (wmi_prune_files) + // runs against an empty temp tree, never the real checkout. + $restore = $GLOBALS['config']['base_path']; + $GLOBALS['config']['base_path'] = sys_get_temp_dir() . '/wmi-upg-' . uniqid(); + + try { + expect(plugin_wmi_upgrade())->toBeTrue(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } }); it('adds the wmi_account column and creates every table', function () { @@ -73,3 +83,9 @@ // 2 fields for the non-indexed query, 4 for the indexed query. expect($inserts)->toHaveCount(6); }); + +it('device_new hook loads the relocated library and returns its payload unchanged', function () { + $save = array('id' => 123); + + expect(wmi_api_device_new($save))->toBe($save); +}); diff --git a/tests/WmiCommandInjectionTest.php b/tests/WmiCommandInjectionTest.php index f8a27ba..8d3f344 100644 --- a/tests/WmiCommandInjectionTest.php +++ b/tests/WmiCommandInjectionTest.php @@ -20,7 +20,7 @@ function cacti_escapeshellarg($s) { } } -require_once __DIR__ . '/../linux_wmi.php'; +require_once __DIR__ . '/../includes/linux_wmi.php'; describe('wmi command injection hardening', function () { it('contains an injected unix hostname inside a quoted argument', function () { diff --git a/tests/WmiEmptySeparatorTest.php b/tests/WmiEmptySeparatorTest.php index 179e266..f5f6002 100644 --- a/tests/WmiEmptySeparatorTest.php +++ b/tests/WmiEmptySeparatorTest.php @@ -23,7 +23,7 @@ function cacti_escapeshellarg($s) { } } -require_once __DIR__ . '/../linux_wmi.php'; +require_once __DIR__ . '/../includes/linux_wmi.php'; /** A transport stub that records the request it received and returns canned rows built with a given separator. */ class Wmi_Test_Recording_Transport implements Wmi_Transport { diff --git a/tests/WmiPowerShellTransportTest.php b/tests/WmiPowerShellTransportTest.php index 706c8fc..c5bc2c8 100644 --- a/tests/WmiPowerShellTransportTest.php +++ b/tests/WmiPowerShellTransportTest.php @@ -20,7 +20,7 @@ function cacti_escapeshellarg($s) { } } -require_once __DIR__ . '/../linux_wmi.php'; +require_once __DIR__ . '/../includes/linux_wmi.php'; /** A runner that records what it was handed and returns canned CIM output. */ function wmi_test_recording_runner(array &$seen, array $stdout, int $exit = 0, string $stderr = ''): callable { diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index e65b53d..ae144f1 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -160,6 +160,25 @@ function changed_lines($base_ref) { * Empty by default; add entries per repository as the need arises. */ $unmeasured_allowlist = [ + // Schema provisioning relocated verbatim from setup.php; only loaded from + // plugin_wmi_install() at install time, not exercisable in the isolated + // unit process, and asserted end-to-end by the lifecycle/install tests. + 'includes/database.php', + + // Web UI pages: each chdir('../../') then require include/auth.php at the + // top level, so they cannot be loaded into the isolated unit process. + // Their structure is asserted by the navigation, realm-registration, + // prepared-statement and tab-wrapper tests rather than by execution. + 'wmi_accounts.php', + 'wmi_queries.php', + 'wmi_tools.php', + + // CLI/script-server entry points: each chdir + require cli_check.php / + // global.php at the top level (poller_wmi.php additionally forks background + // children), none of which is loadable in the isolated unit process. + 'poller_wmi.php', + 'wmi_script.php', + 'script/wmi-script.php', ]; $unmeasured = array_values(array_diff(array_keys($changed), array_keys($measured))); $unexpected_unmeasured = array_values(array_diff($unmeasured, $unmeasured_allowlist)); diff --git a/tests/bin/validate-manifest.php b/tests/bin/validate-manifest.php new file mode 100644 index 0000000..c54fab5 --- /dev/null +++ b/tests/bin/validate-manifest.php @@ -0,0 +1,73 @@ + __('Delete', 'wmi') diff --git a/wmi_queries.php b/wmi_queries.php index ab9692e..d01f467 100644 --- a/wmi_queries.php +++ b/wmi_queries.php @@ -24,9 +24,9 @@ chdir('../../'); -include('./include/auth.php'); -include_once($config['base_path'] . '/plugins/wmi/functions.php'); -include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); +require('./include/auth.php'); +require_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); +require_once($config['base_path'] . '/plugins/wmi/includes/linux_wmi.php'); $ds_actions = [ 1 => __('Delete', 'wmi') diff --git a/wmi_script.php b/wmi_script.php index de8641d..988da82 100644 --- a/wmi_script.php +++ b/wmi_script.php @@ -22,11 +22,11 @@ +-------------------------------------------------------------------------+ */ -include_once(__DIR__ . '/../global/cli_check.php'); -include_once($config['base_path'] . '/lib/snmp.php'); +require_once(__DIR__ . '/../../include/cli_check.php'); +require_once($config['base_path'] . '/lib/snmp.php'); if (!isset($called_by_script_server)) { - include_once(__DIR__ . '/../include/global.php'); + require_once(__DIR__ . '/../include/global.php'); array_shift($_SERVER['argv']); if (isset($_SERVER['argv'][0]) && $_SERVER['argv'][0] == 'wmi_script') { @@ -68,7 +68,7 @@ function wmi_script($hostname, $host_id, $wmiquery, $cmd = '', $arg1 = '', $arg2 = '') { global $config; - include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/includes/linux_wmi.php'); $wmi = new Linux_WMI($host_id); $wmi->hostname = $hostname; diff --git a/wmi_tools.php b/wmi_tools.php index bdec0b6..7dc7d32 100644 --- a/wmi_tools.php +++ b/wmi_tools.php @@ -23,9 +23,9 @@ */ chdir('../../'); -include('./include/auth.php'); -include_once('./lib/snmp.php'); -include_once('./lib/utility.php'); +require('./include/auth.php'); +require_once('./lib/snmp.php'); +require_once('./lib/utility.php'); set_default_action(); @@ -599,7 +599,7 @@ function walk_host() { // Linux_WMI self-selects the PowerShell/CIM transport on a Windows // Cacti server and the wmic shell transport otherwise, so the same // client is used here regardless of $config['cacti_server_os']. - include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/includes/linux_wmi.php'); $wmi = new Linux_WMI(); $wmi->hostname = $host;