diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..63de8ee --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,23 @@ +# Cacti webseer Plugin AI Instructions + +## Project Overview +This is a Cacti plugin. It integrates with the Cacti monitoring platform via the plugin hook architecture. + +## Technology Stack +- PHP 7.4+ (targeting Cacti 1.2.x compatibility) +- MySQL/MariaDB via Cacti's DB abstraction layer +- PSR-12 coding standards + +## Key Rules +- Use prepared statements (db_execute_prepared, db_fetch_row_prepared, etc.) for ALL queries with variables +- Use get_request_var() / get_filter_request_var() for ALL user input, never raw $_REQUEST/$_GET/$_POST +- Use html_escape() / htmlspecialchars() for ALL output of DB/user values in HTML context +- Use cacti_escapeshellarg() for ALL shell command arguments +- No PHP 8.0+ features (str_contains, match, union types, named args) - target PHP 7.4 +- Use ?? and ??= operators (PHP 7.4) instead of isset() ternary patterns +- All unserialize() calls must use allowed_classes => false + +## Testing +- Tests in tests/ directory +- Use Pest PHP or PHPUnit +- php -l lint check required before commit diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml new file mode 100644 index 0000000..614ff81 --- /dev/null +++ b/.github/workflows/plugin-ci-workflow.yml @@ -0,0 +1,225 @@ +# +-------------------------------------------------------------------------+ +# | Copyright (C) 2004-2026 The Cacti Group | +# | | +# | This program is free software; you can redistribute it and/or | +# | modify it under the terms of the GNU General Public License | +# | as published by the Free Software Foundation; either version 2 | +# | of the License, or (at your option) any later version. | +# | | +# | This program is distributed in the hope that it will be useful, | +# | but WITHOUT ANY WARRANTY; without even the implied warranty of | +# | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | +# | GNU General Public License for more details. | +# +-------------------------------------------------------------------------+ +# | Cacti: The Complete RRDtool-based Graphing Solution | +# +-------------------------------------------------------------------------+ +# | This code is designed, written, and maintained by the Cacti Group. See | +# | about.php and/or the AUTHORS file for specific developer information. | +# +-------------------------------------------------------------------------+ +# | http://www.cacti.net/ | +# +-------------------------------------------------------------------------+ + +name: Plugin Integration Tests + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + integration-test: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + os: [ubuntu-latest] + + services: + mariadb: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: cactiroot + MYSQL_DATABASE: cacti + MYSQL_USER: cactiuser + MYSQL_PASSWORD: cactiuser + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + name: PHP ${{ matrix.php }} Integration Test on ${{ matrix.os }} + + steps: + - name: Checkout Cacti + uses: actions/checkout@v4 + with: + repository: Cacti/cacti + path: cacti + + - name: Checkout webseer Plugin + uses: actions/checkout@v4 + with: + path: cacti/plugins/webseer + + - name: Install PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring + ini-values: "post_max_size=256M, max_execution_time=60, date.timezone=America/New_York" + + - name: Check PHP version + run: php -v + + - name: Run apt-get update + run: sudo apt-get update + + - name: Install System Dependencies + run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping libapache2-mod-php${{ matrix.php }} + + - name: Start SNMPD Agent and Test + run: | + sudo systemctl start snmpd + sudo snmpwalk -c public -v2c -On localhost .1.3.6.1.2.1.1 + + - name: Setup Permissions + run: | + sudo chown -R www-data:runner ${{ github.workspace }}/cacti + sudo find ${{ github.workspace }}/cacti -type d -exec chmod 775 {} \; + sudo find ${{ github.workspace }}/cacti -type f -exec chmod 664 {} \; + sudo chmod +x ${{ github.workspace }}/cacti/cmd.php + sudo chmod +x ${{ github.workspace }}/cacti/poller.php + + - name: Create MySQL Config + run: | + echo -e "[client]\nuser = root\npassword = cactiroot\nhost = 127.0.0.1\n" > ~/.my.cnf + cat ~/.my.cnf + + - name: Initialize Cacti Database + env: + MYSQL_AUTH_USR: '--defaults-file=~/.my.cnf' + run: | + mysql $MYSQL_AUTH_USR -e 'CREATE DATABASE IF NOT EXISTS cacti;' + mysql $MYSQL_AUTH_USR -e "CREATE USER IF NOT EXISTS 'cactiuser'@'localhost' IDENTIFIED BY 'cactiuser';" + mysql $MYSQL_AUTH_USR -e "GRANT ALL PRIVILEGES ON cacti.* TO 'cactiuser'@'localhost';" + mysql $MYSQL_AUTH_USR -e "GRANT SELECT ON mysql.time_zone_name TO 'cactiuser'@'localhost';" + mysql $MYSQL_AUTH_USR -e "FLUSH PRIVILEGES;" + mysql $MYSQL_AUTH_USR cacti < ${{ github.workspace }}/cacti/cacti.sql + mysql $MYSQL_AUTH_USR -e "INSERT INTO settings (name, value) VALUES ('path_php_binary', '/usr/bin/php')" cacti + + - name: Validate composer files + run: | + cd ${{ github.workspace }}/cacti + if [ -f composer.json ]; then + composer validate --strict || true + fi + + - name: Install Composer Dependencies + run: | + cd ${{ github.workspace }}/cacti + if [ -f composer.json ]; then + sudo composer install --prefer-dist --no-progress + fi + + - name: Create Cacti config.php + run: | + cat ${{ github.workspace }}/cacti/include/config.php.dist | \ + sed -r "s/localhost/127.0.0.1/g" | \ + sed -r "s/'cacti'/'cacti'/g" | \ + sed -r "s/'cactiuser'/'cactiuser'/g" | \ + sed -r "s/'cactiuser'/'cactiuser'/g" > ${{ github.workspace }}/cacti/include/config.php + sudo chmod 664 ${{ github.workspace }}/cacti/include/config.php + + - name: Configure Apache + run: | + cat << 'EOF' | sed 's#GITHUB_WORKSPACE#${{ github.workspace }}#g' > /tmp/cacti.conf + + ServerAdmin webmaster@localhost + DocumentRoot GITHUB_WORKSPACE/cacti + + + Options Indexes FollowSymLinks + AllowOverride All + Require all granted + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + EOF + sudo cp /tmp/cacti.conf /etc/apache2/sites-available/000-default.conf + sudo systemctl restart apache2 + + - name: Install Cacti via CLI + run: | + cd ${{ github.workspace }}/cacti + sudo php cli/install_cacti.php --accept-eula --install --force + + - name: Install webseer Plugin + run: | + cd ${{ github.workspace }}/cacti + sudo php cli/plugin_manage.php --plugin=webseer --install --enable + +# - name: import webseer Plugin Sample Data +# run: | +# cd ${{ github.workspace }}/cacti/plugins/webseer +# sudo php cli_import.php --filename=.github/workflows/webseer_sample_data.xml +# if [ $? -ne 0 ]; then +# echo "Failed to import Thold sample data" +# exit 1 +# fi + + - name: Check PHP Syntax for Plugin + run: | + cd ${{ github.workspace }}/cacti/plugins/webseer + if find . -name '*.php' -exec php -l {} 2>&1 \; | grep -iv 'no syntax errors detected'; then + echo "Syntax errors found!" + exit 1 + fi + + - name: Remove the plugins directory exclusion from the .phpstan.neon + run: sed '/plugins/d' -i .phpstan.neon + working-directory: ${{ github.workspace }}/cacti + + - name: Mark composer scripts executable + run: sudo chmod +x ${{ github.workspace }}/cacti/include/vendor/bin/* + + - name: Run Linter on base code + run: composer run-script lint ${{ github.workspace }}/cacti/plugins/webseer + working-directory: ${{ github.workspace }}/cacti + + - name: Checking coding standards on base code + run: composer run-script phpcsfixer ${{ github.workspace }}/cacti/plugins/webseer + working-directory: ${{ github.workspace }}/cacti + +# - name: Run PHPStan at Level 6 on base code outside of Composer due to technical issues +# run: ./include/vendor/bin/phpstan analyze --level 6 ${{ github.workspace }}/cacti/plugins/webseer +# working-directory: ${{ github.workspace }}/cacti + + - name: Run Cacti Poller + run: | + cd ${{ github.workspace }}/cacti + sudo php poller.php --poller=1 --force --debug + if ! grep -q "SYSTEM STATS" log/cacti.log; then + echo "Cacti poller did not finish successfully" + cat log/cacti.log + exit 1 + fi + + - name: View Cacti Logs + if: always() + run: | + if [ -f ${{ github.workspace }}/cacti/log/cacti.log ]; then + echo "=== Cacti Log ===" + sudo cat ${{ github.workspace }}/cacti/log/cacti.log + fi diff --git a/includes/functions.php b/includes/functions.php index f8dd2ed..ce2acf6 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -72,7 +72,7 @@ function plugin_webseer_refresh_servers() { foreach ($results as $r) { if (substr($r, 0, 8) == 'SERVERS=') { $servers = substr($r, 8); - $servers = unserialize(base64_decode($servers)); + $servers = unserialize(base64_decode($servers), ['allowed_classes' => false]); if (isset($servers[0]['id'])) { db_execute('TRUNCATE TABLE plugin_webseer_servers'); foreach ($servers as $save) { @@ -104,7 +104,7 @@ function plugin_webseer_refresh_urls () { foreach ($results as $r) { if (substr($r, 0, 5) == 'URLS=') { $urls = substr($r, 5); - $urls = unserialize(base64_decode($urls)); + $urls = unserialize(base64_decode($urls), ['allowed_classes' => false]); if (isset($urls[0]['id'])) { db_execute('TRUNCATE TABLE plugin_webseer_urls'); @@ -316,12 +316,18 @@ function plugin_webseer_update_contacts() { $users = db_fetch_assoc("SELECT id, 'email' AS type, email_address FROM user_auth WHERE email_address!=''"); if (cacti_sizeof($users)) { foreach($users as $u) { - $cid = db_fetch_cell('SELECT id FROM plugin_webseer_contacts WHERE type="email" AND user_id=' . $u['id']); + $cid = db_fetch_cell_prepared('SELECT id FROM plugin_webseer_contacts WHERE type="email" AND user_id=?', [$u['id']]); if ($cid) { - db_execute("REPLACE INTO plugin_webseer_contacts (id, user_id, type, data) VALUES ($cid, " . $u['id'] . ", 'email', '" . $u['email_address'] . "')"); + db_execute_prepared( + 'REPLACE INTO plugin_webseer_contacts (id, user_id, type, data) VALUES (?, ?, \'email\', ?)', + [$cid, $u['id'], $u['email_address']] + ); } else { - db_execute("REPLACE INTO plugin_webseer_contacts (user_id, type, data) VALUES (" . $u['id'] . ", 'email', '" . $u['email_address'] . "')"); + db_execute_prepared( + 'REPLACE INTO plugin_webseer_contacts (user_id, type, data) VALUES (?, \'email\', ?)', + [$u['id'], $u['email_address']] + ); } } } diff --git a/poller_webseer.php b/poller_webseer.php index 2efc9fc..9470d4d 100644 --- a/poller_webseer.php +++ b/poller_webseer.php @@ -238,7 +238,7 @@ function plugin_webseer_update_servers() { foreach ($servers as $server) { $server['debug_type'] = 'Server'; - $cc = new cURL(true, 'cookies.txt', $server['compression'], '', $server);; + $cc = new cURL(true, 'cookies.txt', $server['compression'], '', $server); $data = array(); $data['action'] = 'HEARTBEAT'; diff --git a/remote.php b/remote.php index ab3aff6..1beacca 100644 --- a/remote.php +++ b/remote.php @@ -172,7 +172,7 @@ case 'SETMASTER': if (isset($_POST['ip'])) { $ip = str_replace(array("'", '\\'), '', $_POST['ip']); - $row = db_fetch_row("SELECT * FROM plugin_webseer_servers WHERE ip = '$ip'"); + $row = db_fetch_row_prepared('SELECT * FROM plugin_webseer_servers WHERE ip = ?', array($ip)); if (isset($row['id'])) { db_execute('UPDATE plugin_webseer_servers set master = 0'); db_execute_prepared('UPDATE plugin_webseer_servers set master = 1 WHERE ip = ?', array($ip)); @@ -192,4 +192,3 @@ } } } - diff --git a/tests/e2e/test_webseer_no_raw_reuse_regression.php b/tests/e2e/test_webseer_no_raw_reuse_regression.php new file mode 100644 index 0000000..ba2bf6d --- /dev/null +++ b/tests/e2e/test_webseer_no_raw_reuse_regression.php @@ -0,0 +1,35 @@ +", + "
  • ' . db_fetch_cell_prepared('SELECT name FROM plugin_webseer_proxies WHERE id = ?', array(\$matches[1])) . '
  • '", + "
  • ' . db_fetch_cell_prepared('SELECT display_name FROM plugin_webseer_urls WHERE id = ?', array(\$matches[1])) . '
  • '", + "
  • ' . db_fetch_cell_prepared('SELECT name FROM plugin_webseer_servers WHERE id = ?', array(\$matches[1])) . '
  • '", + ' name LIKE "%\' . get_request_var(\'filter\') . \'%" OR hostname LIKE "%\' . get_request_var(\'filter\') . \'%"', +); + +foreach ($files as $file) { + $source = file_get_contents(dirname(__DIR__, 2) . '/' . $file); + + if ($source === false) { + fwrite(STDERR, "Unable to read $file\n"); + exit(1); + } + + foreach ($legacy_needles as $needle) { + if (strpos($source, $needle) !== false) { + fwrite(STDERR, "Found legacy insecure pattern in $file\n"); + exit(1); + } + } +} + +echo "OK\n"; diff --git a/tests/integration/test_webseer_confirmation_and_sql_wiring.php b/tests/integration/test_webseer_confirmation_and_sql_wiring.php new file mode 100644 index 0000000..67ef6c2 --- /dev/null +++ b/tests/integration/test_webseer_confirmation_and_sql_wiring.php @@ -0,0 +1,23 @@ +\' . html_escape(db_fetch_cell_prepared(\'SELECT name FROM plugin_webseer_proxies WHERE id = ?\', array($matches[1]))) . \'') !== false, + $proxy_source !== false && strpos($proxy_source, "(name LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ' OR hostname LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ')") !== false, + $url_source !== false && strpos($url_source, '
  • \' . html_escape(db_fetch_cell_prepared(\'SELECT display_name FROM plugin_webseer_urls WHERE id = ?\', array($matches[1]))) . \'
  • ') !== false, + $server_source !== false && strpos($server_source, '
  • \' . html_escape(db_fetch_cell_prepared(\'SELECT name FROM plugin_webseer_servers WHERE id = ?\', array($matches[1]))) . \'
  • ') !== false, + $remote_source !== false && strpos($remote_source, "db_fetch_row_prepared('SELECT * FROM plugin_webseer_servers WHERE ip = ?', array(\$ip))") !== false, +); + +foreach ($checks as $passed) { + if (!$passed) { + fwrite(STDERR, "Webseer security wiring check failed\n"); + exit(1); + } +} + +echo "OK\n"; diff --git a/tests/unit/test_webseer_security_guards.php b/tests/unit/test_webseer_security_guards.php new file mode 100644 index 0000000..526bf6d --- /dev/null +++ b/tests/unit/test_webseer_security_guards.php @@ -0,0 +1,38 @@ + array( + "html_escape(db_fetch_cell_prepared('SELECT name FROM plugin_webseer_proxies WHERE id = ?', array(\$matches[1])))", + "html_escape(get_nfilter_request_var('drp_action'))", + "(name LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ' OR hostname LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ')'", + ), + 'webseer.php' => array( + "html_escape(db_fetch_cell_prepared('SELECT display_name FROM plugin_webseer_urls WHERE id = ?', array(\$matches[1])))", + "html_escape(get_nfilter_request_var('drp_action'))", + ), + 'webseer_servers.php' => array( + "html_escape(db_fetch_cell_prepared('SELECT name FROM plugin_webseer_servers WHERE id = ?', array(\$matches[1])))", + "html_escape(get_nfilter_request_var('drp_action'))", + ), + 'remote.php' => array( + "db_fetch_row_prepared('SELECT * FROM plugin_webseer_servers WHERE ip = ?', array(\$ip))", + ), +); + +foreach ($files as $file => $needles) { + $source = file_get_contents(dirname(__DIR__, 2) . '/' . $file); + + if ($source === false) { + fwrite(STDERR, "Unable to read $file\n"); + exit(1); + } + + foreach ($needles as $needle) { + if (strpos($source, $needle) === false) { + fwrite(STDERR, "Missing expected guard in $file\n"); + exit(1); + } + } +} + +echo "OK\n"; diff --git a/webseer.php b/webseer.php index 767914c..a7a10ed 100644 --- a/webseer.php +++ b/webseer.php @@ -174,7 +174,7 @@ function form_actions() { input_validate_input_number($matches[1]); /* ==================================================== */ - $url_list .= '
  • ' . db_fetch_cell_prepared('SELECT display_name FROM plugin_webseer_urls WHERE id = ?', array($matches[1])) . '
  • '; + $url_list .= '
  • ' . html_escape(db_fetch_cell_prepared('SELECT display_name FROM plugin_webseer_urls WHERE id = ?', array($matches[1]))) . '
  • '; $url_array[] = $matches[1]; } } @@ -235,7 +235,7 @@ function form_actions() { - + $save_html \n"; @@ -741,12 +741,13 @@ function list_urls() { $sql_limit = ' LIMIT ' . ($rows*(get_request_var('page')-1)) . ',' . $rows; if (get_request_var('rfilter') != '') { - $sql_where .= ($sql_where == '' ? 'WHERE ' : ' AND ') . - 'display_name RLIKE \'' . get_request_var('rfilter') . '\' OR ' . - 'url RLIKE \'' . get_request_var('rfilter') . '\' OR ' . - 'search RLIKE \'' . get_request_var('rfilter') . '\' OR ' . - 'search_maint RLIKE \'' . get_request_var('rfilter') . '\' OR ' . - 'search_failed RLIKE \'' . get_request_var('rfilter') . '\''; + $rfilter = db_qstr(get_request_var('rfilter')); + $sql_where .= ($sql_where == '' ? 'WHERE ' : ' AND ') . + 'display_name RLIKE ' . $rfilter . ' OR ' . + 'url RLIKE ' . $rfilter . ' OR ' . + 'search RLIKE ' . $rfilter . ' OR ' . + 'search_maint RLIKE ' . $rfilter . ' OR ' . + 'search_failed RLIKE ' . $rfilter; } $result = db_fetch_assoc("SELECT * @@ -1158,4 +1159,3 @@ function purgeEvents() { ' . db_fetch_cell_prepared('SELECT name FROM plugin_webseer_proxies WHERE id = ?', array($matches[1])) . ''; + $proxy_list .= '
  • ' . html_escape(db_fetch_cell_prepared('SELECT name FROM plugin_webseer_proxies WHERE id = ?', array($matches[1]))) . '
  • '; $proxy_array[] = $matches[1]; } } @@ -117,7 +117,7 @@ function proxy_form_actions() { - + $save_html \n"; @@ -180,7 +180,7 @@ function proxy_edit() { draw_edit_form( array( 'config' => array('no_form_tag' => true), - 'fields' => inject_form_variables($webseer_proxy_fields, (isset($proxy) ? $proxy : array())) + 'fields' => inject_form_variables($webseer_proxy_fields, ($proxy ?? array())) ) ); @@ -252,7 +252,7 @@ function proxies() { $sql_where = ''; if (get_request_var('filter') != '') { - $sql_where .= ($sql_where == '' ? 'WHERE ' : ' AND ') . ' name LIKE "%' . get_request_var('filter') . '%" OR hostname LIKE "%' . get_request_var('filter') . '%"'; + $sql_where .= ($sql_where == '' ? 'WHERE ' : ' AND ') . '(name LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ' OR hostname LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ')'; } $sql_order = get_order_string(); @@ -404,4 +404,3 @@ function clearFilter() { ' . db_fetch_cell_prepared('SELECT name FROM plugin_webseer_servers WHERE id = ?', array($matches[1])) . ''; + $server_list .= '
  • ' . html_escape(db_fetch_cell_prepared('SELECT name FROM plugin_webseer_servers WHERE id = ?', array($matches[1]))) . '
  • '; $server_array[] = $matches[1]; } } @@ -183,7 +183,7 @@ function form_actions() { - + $save_html "; @@ -870,4 +870,3 @@ function clearFilter() {