From dcebf40f4cfd3840be8db65e8a117b3b42f3ea74 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 15 Mar 2026 16:58:40 -0700 Subject: [PATCH 1/9] ci: add dependabot config with npm and github-actions --- .github/dependabot.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5c8ba20 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 From 7f369383202c2dbddf1129cc0b8366284555054d Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 15 Mar 2026 16:58:41 -0700 Subject: [PATCH 2/9] ci: add CodeQL (JS/TS, excludes PHP) with workflow_dispatch --- .github/workflows/codeql.yml | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..e8b70b5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,47 @@ +name: "CodeQL" + +on: + push: + branches: [main, master, develop] + paths-ignore: + - "**/*.php" + - "**/*.md" + pull_request: + branches: [main, master, develop] + paths-ignore: + - "**/*.php" + - "**/*.md" + schedule: + - cron: "30 1 * * 1" + workflow_dispatch: + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: ["javascript-typescript"] + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3 + with: + languages: ${{ matrix.language }} + - name: Autobuild + uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3 + with: + category: "/language:${{ matrix.language }}" From 93c0fd64eab36743814e0de6bce49ddd7466cb12 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 15 Mar 2026 18:55:38 -0700 Subject: [PATCH 3/9] ci: remove CodeQL JS/TS workflow (no JavaScript in this repo) --- .github/workflows/codeql.yml | 47 ------------------------------------ 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index e8b70b5..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [main, master, develop] - paths-ignore: - - "**/*.php" - - "**/*.md" - pull_request: - branches: [main, master, develop] - paths-ignore: - - "**/*.php" - - "**/*.md" - schedule: - - cron: "30 1 * * 1" - workflow_dispatch: - -concurrency: - group: codeql-${{ github.ref }} - cancel-in-progress: true - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: ["javascript-typescript"] - steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - name: Initialize CodeQL - uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3 - with: - languages: ${{ matrix.language }} - - name: Autobuild - uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3 - with: - category: "/language:${{ matrix.language }}" From de54e8c3f1a11f100af7a172b8a06c23683503ad Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Wed, 8 Apr 2026 23:55:45 -0700 Subject: [PATCH 4/9] 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 | 101 +++++++++ .../PreparedStatementConsistencyTest.php | 59 ++++++ tests/Security/SetupStructureTest.php | 36 ++++ tests/bootstrap.php | 200 ++++++++++++++++++ 6 files changed, 428 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..2275b42 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "cacti/plugin_maint", + "description": "plugin_maint 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..b56e671 --- /dev/null +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -0,0 +1,59 @@ +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..44d6621 --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,36 @@ +toContain('function plugin_maint_install'); + }); + + it('defines plugin_maint_version function', function () use ($source) { + expect($source)->toContain('function plugin_maint_version'); + }); + + it('defines plugin_maint_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_maint_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 5917961879459ca560c8b45dd4817ec14cee7e5e Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Thu, 9 Apr 2026 23:01:50 -0700 Subject: [PATCH 5/9] 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 --- .github/dependabot.yml | 2 +- .gitignore | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5c8ba20..b14cfa0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: "npm" + - package-ecosystem: "composer" directory: "/" schedule: interval: "weekly" diff --git a/.gitignore b/.gitignore index eb71606..32791f0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ # +-------------------------------------------------------------------------+ locales/po/*.mo +.omc/ From 57900879a9c5990cfaffedad9b20894c2b834630 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Tue, 14 Jul 2026 07:31:09 -0700 Subject: [PATCH 6/9] style: align plugin with Cacti PHP coding standard --- tests/Pest.php | 4 +--- tests/Security/Php74CompatibilityTest.php | 4 ++-- .../PreparedStatementConsistencyTest.php | 8 +++---- tests/Security/SetupStructureTest.php | 4 +--- tests/bootstrap.php | 24 ++++++++++--------- 5 files changed, 21 insertions(+), 23 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'; diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php index 5cb7e3a..edb6e2d 100644 --- a/tests/Security/Php74CompatibilityTest.php +++ b/tests/Security/Php74CompatibilityTest.php @@ -13,11 +13,11 @@ */ describe('PHP 7.4 compatibility in maint', function () { - $files = array( + $files = [ 'functions.php', 'maint.php', 'setup.php', - ); + ]; it('does not use str_contains (PHP 8.0)', function () use ($files) { foreach ($files as $relativeFile) { diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index b56e671..a1b70b6 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -14,13 +14,13 @@ describe('prepared statement consistency in maint', function () { it('uses prepared DB helpers in all plugin files', function () { - $targetFiles = array( + $targetFiles = [ 'functions.php', 'maint.php', 'setup.php', - ); + ]; - $rawPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(/'; + $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) { @@ -36,7 +36,7 @@ continue; } - $lines = explode("\n", $contents); + $lines = explode("\n", $contents); $rawCallsOutsideComments = 0; foreach ($lines as $line) { diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php index 44d6621..4ee572b 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. describe('maint setup.php structure', function () { $source = file_get_contents(realpath(__DIR__ . '/../../setup.php')); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0aa22f2..bb20bc6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,43 +12,45 @@ * can be loaded in isolation without the full Cacti application. */ -$GLOBALS['__test_db_calls'] = array(); +$GLOBALS['__test_db_calls'] = []; 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 []; } } @@ -59,7 +61,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 cbfdd2b5019ba69a8ea5a8411dd79527aa0d4a3b Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Tue, 14 Jul 2026 07:34:05 -0700 Subject: [PATCH 7/9] ci: remove unavailable versioned Apache PHP package --- .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 1780696..3f5574c 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -86,7 +86,7 @@ jobs: 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 }} + run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping - name: Start SNMPD Agent and Test run: | From 847ac39d8ea3fd51ca85ab06aca944d489eb8117 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Tue, 14 Jul 2026 07:36:46 -0700 Subject: [PATCH 8/9] ci: exclude test bootstrap from plugin PHPStan analysis --- .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 3f5574c..400a083 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -195,7 +195,7 @@ jobs: 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/maint + run: ./include/vendor/bin/phpstan analyze --level 6 --exclude tests ${{ github.workspace }}/cacti/plugins/maint working-directory: ${{ github.workspace }}/cacti - name: Run Cacti Poller From 539590fce0c83f68c9620fe719a7096156f91a7e Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Tue, 14 Jul 2026 07:45:14 -0700 Subject: [PATCH 9/9] ci: analyze production PHP files without test fixtures --- .github/workflows/plugin-ci-workflow.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 400a083..02869ae 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -195,7 +195,9 @@ jobs: 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 --exclude tests ${{ github.workspace }}/cacti/plugins/maint + run: | + find ${{ github.workspace }}/cacti/plugins/maint -type f -name '*.php' ! -path '*/tests/*' -print0 | + xargs -0 ./include/vendor/bin/phpstan analyze --level 6 working-directory: ${{ github.workspace }}/cacti - name: Run Cacti Poller