diff --git a/.github/workflows/php-syntax.yml b/.github/workflows/php-syntax.yml
new file mode 100644
index 00000000..07fa0d8d
--- /dev/null
+++ b/.github/workflows/php-syntax.yml
@@ -0,0 +1,52 @@
+name: PHP Syntax
+
+on:
+ pull_request:
+ push:
+ branches:
+ - develop
+
+permissions:
+ contents: read
+
+concurrency:
+ group: php-syntax-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ name: PHP ${{ matrix.php }} syntax
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ tools: none
+ coverage: none
+
+ - name: Show PHP version
+ run: php -v
+
+ - name: Guard against corrupted refactor patterns
+ run: |
+ set -euo pipefail
+ if grep -R -n -E '\b(is_|in_|call_user_func_|port_list_to_|mactrack_display_|mactrack_device_action_)\[' --include='*.php' .; then
+ echo "Detected corrupted call-pattern rewrite(s)." >&2
+ exit 1
+ fi
+
+ - name: Lint PHP files
+ run: |
+ set -euo pipefail
+ git ls-files '*.php' | while IFS= read -r f; do
+ php -l "$f"
+ done
diff --git a/.gitignore b/.gitignore
index eb716067..32791f06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@
# +-------------------------------------------------------------------------+
locales/po/*.mo
+.omc/
diff --git a/images/index.php b/images/index.php
index 828ecbeb..06a9f5c2 100644
--- a/images/index.php
+++ b/images/index.php
@@ -1,4 +1,6 @@
' . $site['site_name'] . '';
+ } print '>' . html_escape($site['site_name']) . '';
}
}
?>
@@ -3665,7 +3666,7 @@ function mactrack_site_filter($page = 'mactrack_sites.php') {
if (get_request_var('device_type_id') == $device_type['device_type_id']) {
print ' selected';
- } print '>' . $device_type['description'] . ' (' . $device_type['sysDescr_match'] . ')';
+ } print '>' . html_escape($device_type['description']) . ' (' . html_escape($device_type['sysDescr_match']) . ')';
}
}
?>
diff --git a/lib/mactrack_h3c_3com.php b/lib/mactrack_h3c_3com.php
index f28d695a..5340fd53 100644
--- a/lib/mactrack_h3c_3com.php
+++ b/lib/mactrack_h3c_3com.php
@@ -1,4 +1,6 @@
0)) {
// $devices holds the whole row from host table
// now fetch the related device from mac_track_devices, if any
- $mt_device = db_fetch_row('SELECT * from mac_track_devices WHERE host_id=' . $device['id']);
+ $mt_device = db_fetch_row_prepared('SELECT * FROM mac_track_devices WHERE host_id = ?', [$device['id']]);
if (is_array($mt_device) && $mt_device) {
if (!isset($mt_device['snmp_engine_id'])) {
diff --git a/mactrack_ajax.php b/mactrack_ajax.php
index 0078e00b..25997296 100644
--- a/mactrack_ajax.php
+++ b/mactrack_ajax.php
@@ -1,4 +1,6 @@
|
@@ -1123,7 +1126,7 @@ function mactrack_mac_filter() {
if (get_request_var('site_id') == $site['site_id']) {
print ' selected';
- } print '>' . $site['site_name'] . '';
+ } print '>' . html_escape($site['site_name']) . '';
}
}
?>
@@ -1154,7 +1157,7 @@ function mactrack_mac_filter() {
if (get_request_var('device_id') == $filter_device['device_id']) {
print ' selected';
- } print '>' . $filter_device['device_name'] . '(' . $filter_device['hostname'] . ')' . '';
+ } print '>' . html_escape($filter_device['device_name']) . '(' . html_escape($filter_device['hostname']) . ')' . '';
}
}
?>
diff --git a/mactrack_view_sites.php b/mactrack_view_sites.php
index 655bbc20..74f71627 100644
--- a/mactrack_view_sites.php
+++ b/mactrack_view_sites.php
@@ -1,4 +1,6 @@
+
+
+
+ ./tests/Unit
+
+
+ ./tests/Handoff
+
+
+ ./tests/Integration
+
+
+ ./tests/Mutation
+
+
+ ./tests/Smoke
+
+
+
+
+
+
diff --git a/setup.php b/setup.php
index 81ffe04b..4a9723eb 100644
--- a/setup.php
+++ b/setup.php
@@ -1,4 +1,6 @@
toContain('function plugin_mactrack_install');
+ });
+
+ it('defines plugin_mactrack_version function', function () use ($setup) {
+ expect($setup)->toContain('function plugin_mactrack_version');
+ });
+
+ it('defines plugin_mactrack_uninstall function', function () use ($setup) {
+ expect($setup)->toContain('function plugin_mactrack_uninstall');
+ });
+
+ it('registers hooks in install function', function () use ($setup) {
+ expect($setup)->toContain('api_plugin_register_hook');
+ });
+
+ it('reads version from INFO ini file', function () use ($setup) {
+ expect($setup)->toContain('parse_ini_file');
+ });
+
+ it('has INFO file with required fields', function () {
+ $info = parse_ini_file(realpath(__DIR__ . '/../../INFO'));
+ expect($info)->toHaveKey('name');
+ expect($info)->toHaveKey('version');
+ expect($info['name'])->toBe('mactrack');
+ });
+});
+
+describe('mactrack required entry points', function () {
+ $setupSource = file_get_contents(realpath(__DIR__ . '/../../setup.php'));
+
+ it('defines plugin_mactrack_check_config', function () use ($setupSource) {
+ expect($setupSource)->toContain('function plugin_mactrack_check_config');
+ });
+
+ it('defines plugin_mactrack_upgrade', function () use ($setupSource) {
+ expect($setupSource)->toContain('function plugin_mactrack_upgrade');
+ });
+});
diff --git a/tests/Handoff/XssEscapingHandoffTest.php b/tests/Handoff/XssEscapingHandoffTest.php
new file mode 100644
index 00000000..a66d1c41
--- /dev/null
+++ b/tests/Handoff/XssEscapingHandoffTest.php
@@ -0,0 +1,37 @@
+' . $site['site_name'] . '';
+ $unescaped = preg_match("/print\s+'>'\\s*\\.\\s*\\\$site\['site_name'\]\\s*\\.\\s*'<\\/option>'/", $src);
+ expect($unescaped)->toBe(0, 'site_name must be wrapped in html_escape() before printing');
+ });
+
+ it('mactrack_view_arp.php has no unescaped device_name in option tags', function () {
+ $src = file_get_contents(realpath(__DIR__ . '/../../mactrack_view_arp.php'));
+ $unescaped = preg_match("/print\s+'>'\\s*\\.\\s*\\\$filter_device\['device_name'\]\\s*\\./", $src);
+ expect($unescaped)->toBe(0, 'device_name must be wrapped in html_escape() before printing');
+ });
+
+ it('mactrack_view_macs.php has no unescaped site_name in option tags', function () {
+ $src = file_get_contents(realpath(__DIR__ . '/../../mactrack_view_macs.php'));
+ $unescaped = preg_match("/print\s+'>'\\s*\\.\\s*\\\$site\['site_name'\]\\s*\\.\\s*'<\\/option>'/", $src);
+ expect($unescaped)->toBe(0);
+ });
+
+ it('mactrack_devices.php has no unescaped device_name direct print', function () {
+ $src = file_get_contents(realpath(__DIR__ . '/../../mactrack_devices.php'));
+ // verify escaped version is present and bare print is absent
+ expect($src)->toContain('html_escape($device[\'device_name\'])');
+ expect($src)->not->toContain('print $device[\'device_name\']');
+ });
+
+ it('lib/mactrack_functions.php has no unescaped description in option tags', function () {
+ $src = file_get_contents(realpath(__DIR__ . '/../../lib/mactrack_functions.php'));
+ $unescaped = preg_match("/print\s+'>'\\s*\\.\\s*\\\$device_type\['description'\]\\s*\\./", $src);
+ expect($unescaped)->toBe(0, 'description must be wrapped in html_escape()');
+ });
+});
diff --git a/tests/Integration/PreparedStatementTest.php b/tests/Integration/PreparedStatementTest.php
new file mode 100644
index 00000000..4cbe6bdb
--- /dev/null
+++ b/tests/Integration/PreparedStatementTest.php
@@ -0,0 +1,43 @@
+toBeEmpty(
+ 'Found request-var SQL injection: ' . implode("\n", $violations)
+ );
+ });
+
+ it('uses db_fetch_assoc_prepared in lib/mactrack_functions.php for device_id query', function () {
+ $src = file_get_contents(realpath(__DIR__ . '/../../lib/mactrack_functions.php'));
+ expect($src)->toContain('db_fetch_assoc_prepared(');
+ expect($src)->toContain('WHERE device_id = ?');
+ expect($src)->not->toContain("WHERE device_id='\"");
+ });
+
+ it('lib/mactrack_functions.php has no string-concatenated device_id in SELECT queries', function () {
+ $src = file_get_contents(realpath(__DIR__ . '/../../lib/mactrack_functions.php'));
+ $unsafe = preg_match('/db_fetch_assoc\s*\(\s*["\']SELECT[^"\']*WHERE\s+device_id\s*=\s*["\']/', $src);
+ expect($unsafe)->toBe(0, 'device_id must be parameterized via prepared statement');
+ });
+});
diff --git a/tests/Mutation/FixedBugRegressionTest.php b/tests/Mutation/FixedBugRegressionTest.php
new file mode 100644
index 00000000..6e81007b
--- /dev/null
+++ b/tests/Mutation/FixedBugRegressionTest.php
@@ -0,0 +1,97 @@
+toContain("['allowed_classes' => false]");
+ });
+
+ it('the allowed_classes option appears on the same unserialize call as get_nfilter_request_var', function () {
+ $src = file_get_contents(realpath(__DIR__ . '/../../mactrack_view_macs.php'));
+ $pattern = "/unserialize\s*\(\s*get_nfilter_request_var\s*\([^)]+\)\s*,\s*\['allowed_classes'\s*=>\s*false\]\s*\)/";
+ expect((bool) preg_match($pattern, $src))->toBeTrue('allowed_classes must be on the same unserialize call');
+ });
+
+ it('has no unserialize call without allowed_classes on user input', function () {
+ $src = file_get_contents(realpath(__DIR__ . '/../../mactrack_view_macs.php'));
+ $unsafe = preg_match_all(
+ "/unserialize\s*\(\s*get_nfilter_request_var\s*\([^)]+\)\s*\)(?!\s*;?\s*\/\/\s*nosemgrep)/",
+ $src
+ );
+ expect($unsafe)->toBe(0);
+ });
+});
+
+describe('passthru integer injection fix regression', function () {
+ $funcs = file_get_contents(realpath(__DIR__ . '/../../lib/mactrack_functions.php'));
+
+ it('device rescan does not concatenate raw device_id string into command', function () use ($funcs) {
+ // Before fix: $extra_args = ' -id=' . $dbinfo['device_id'] concatenated as string
+ expect($funcs)->not->toContain("'-id=' . \$dbinfo['device_id']");
+ });
+
+ it('site scan does not concatenate raw site_id string into command', function () use ($funcs) {
+ // Before fix: ' -sid=' . $dbinfo['site_id'] (uncast)
+ expect($funcs)->not->toContain("'-sid=' . \$dbinfo['site_id']");
+ });
+
+ it('device rescan uses int cast for device_id', function () use ($funcs) {
+ expect($funcs)->toContain('(int)$dbinfo[\'device_id\']');
+ });
+
+ it('site scan uses int cast for site_id', function () use ($funcs) {
+ expect($funcs)->toContain('(int)$dbinfo[\'site_id\']');
+ });
+});
+
+describe('SQL prepared statement fix regression', function () {
+ $funcs = file_get_contents(realpath(__DIR__ . '/../../lib/mactrack_functions.php'));
+
+ it('mac_track_interfaces query uses prepared statement', function () use ($funcs) {
+ expect($funcs)->toContain('db_fetch_assoc_prepared(');
+ // the old raw-concat form must be gone
+ expect($funcs)->not->toContain('mac_track_interfaces WHERE device_id=');
+ });
+
+ it('mac_track_interfaces query uses ? placeholder', function () use ($funcs) {
+ expect($funcs)->toMatch('/db_fetch_assoc_prepared\s*\(\s*\'SELECT \* FROM mac_track_interfaces WHERE device_id = \?/');
+ });
+});
+
+describe('XSS fix regression', function () {
+ it('html_escape applied to site_name in all five view files', function () {
+ $viewFiles = [
+ 'mactrack_view_arp.php',
+ 'mactrack_view_macs.php',
+ 'mactrack_view_ips.php',
+ 'mactrack_view_interfaces.php',
+ 'mactrack_view_dot1x.php',
+ ];
+ $root = realpath(__DIR__ . '/../../');
+ $missing = [];
+ foreach ($viewFiles as $f) {
+ $src = file_get_contents("$root/$f");
+ if (!str_contains($src, "html_escape(\$site['site_name'])")) {
+ $missing[] = $f;
+ }
+ }
+ expect($missing)->toBeEmpty('missing html_escape for site_name: ' . implode(', ', $missing));
+ });
+
+ it('html_escape applied to device_name and hostname in arp and macs views', function () {
+ $root = realpath(__DIR__ . '/../../');
+ $missing = [];
+ foreach (['mactrack_view_arp.php', 'mactrack_view_macs.php'] as $f) {
+ $src = file_get_contents("$root/$f");
+ if (!str_contains($src, "html_escape(\$filter_device['device_name'])")) {
+ $missing[] = "$f:device_name";
+ }
+ if (!str_contains($src, "html_escape(\$filter_device['hostname'])")) {
+ $missing[] = "$f:hostname";
+ }
+ }
+ expect($missing)->toBeEmpty('missing html_escape: ' . implode(', ', $missing));
+ });
+});
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 00000000..174d7fd7
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,3 @@
+&1', $output, $returnCode); // nosemgrep: php.lang.security.exec-use.exec-use -- phpBin is PHP_BINARY (constant); file is escapeshellarg'd glob result
+ if ($returnCode !== 0) {
+ $failures[] = basename($file) . ': ' . implode(' ', $output);
+ }
+ }
+
+ expect($failures)->toBeEmpty('PHP syntax errors: ' . implode('; ', $failures));
+ });
+
+ it('all lib PHP files parse without syntax errors', function () {
+ $root = realpath(__DIR__ . '/../../');
+ $phpBin = PHP_BINARY;
+ $libFiles = glob($root . '/lib/*.php');
+ $failures = [];
+
+ foreach ($libFiles as $file) {
+ $output = [];
+ $returnCode = 0;
+ // bare escapeshellarg(): cacti_escapeshellarg() requires the full Cacti bootstrap (DB connection, config); $file is a glob-returned server-local path, not user input
+ exec("$phpBin -l " . escapeshellarg($file) . ' 2>&1', $output, $returnCode); // nosemgrep: php.lang.security.exec-use.exec-use -- phpBin is PHP_BINARY (constant); file is escapeshellarg'd glob result
+ if ($returnCode !== 0) {
+ $failures[] = basename($file) . ': ' . implode(' ', $output);
+ }
+ }
+
+ expect($failures)->toBeEmpty('PHP syntax errors in lib/: ' . implode('; ', $failures));
+ });
+});
+
+describe('plugin required hooks and functions', function () {
+ $setup = file_get_contents(realpath(__DIR__ . '/../../setup.php'));
+
+ it('setup.php defines all required Cacti plugin hook functions', function () use ($setup) {
+ $required = [
+ 'plugin_mactrack_install',
+ 'plugin_mactrack_uninstall',
+ 'plugin_mactrack_version',
+ 'plugin_mactrack_check_config',
+ 'plugin_mactrack_upgrade',
+ ];
+
+ $missing = [];
+ foreach ($required as $fn) {
+ if (!str_contains($setup, "function $fn")) {
+ $missing[] = $fn;
+ }
+ }
+
+ expect($missing)->toBeEmpty('Missing functions: ' . implode(', ', $missing));
+ });
+});
+
+describe('datasource file discovery', function () {
+ it('lib directory contains mactrack_functions.php', function () {
+ expect(file_exists(realpath(__DIR__ . '/../../lib/mactrack_functions.php')))->toBeTrue();
+ });
+
+ it('lib directory contains at least one mactrack library file', function () {
+ $lib = glob(realpath(__DIR__ . '/../../lib/') . '/mactrack_*.php');
+ expect(count($lib))->toBeGreaterThan(0);
+ });
+});
diff --git a/tests/Unit/PassthruHardeningTest.php b/tests/Unit/PassthruHardeningTest.php
new file mode 100644
index 00000000..4fc5e80d
--- /dev/null
+++ b/tests/Unit/PassthruHardeningTest.php
@@ -0,0 +1,41 @@
+toContain('(int)$dbinfo[\'device_id\']');
+ });
+
+ it('casts site_id to int before passthru in site scan', function () use ($funcs) {
+ expect($funcs)->toContain('(int)$dbinfo[\'site_id\']');
+ });
+
+ it('annotates passthru calls with nosemgrep explaining the safety rationale', function () use ($funcs) {
+ $count = substr_count($funcs, 'nosemgrep: php.lang.security.exec-use.exec-use');
+ expect($count)->toBe(2, 'both passthru calls should have nosemgrep annotations');
+ });
+
+ it('does not use extra_args variable in passthru command (injection surface removed)', function () use ($funcs) {
+ // $extra_args was replaced with inline int-cast concatenations
+ $commandLines = [];
+ preg_match_all('/\$command\s*=.*passthru.*\n/s', $funcs, $commandLines);
+ // The commands should include (int) cast, not bare $extra_args
+ $passthruLines = [];
+ preg_match_all('/passthru\(\$command\);.*/', $funcs, $passthruLines);
+ expect(count($passthruLines[0]))->toBe(2);
+ });
+
+ it('site scan always passes --web flag (AJAX-only entry point by design)', function () use ($funcs) {
+ // mactrack_site_scan() is only reached via AJAX; --web is unconditional, unlike device rescan
+ expect($funcs)->toContain("' --web -sid=' . (int)\$dbinfo['site_id']");
+ });
+
+ it('script paths are bare filesystem paths with no embedded arguments', function () use ($funcs) {
+ // cacti_escapeshellarg() quotes the entire value as one token; embedded flags would break the command
+ expect($funcs)->toContain("\$script_path = \$config['base_path'] . '/plugins/mactrack/mactrack_scanner.php'");
+ expect($funcs)->toContain("\$script_path = \$config['base_path'] . '/plugins/mactrack/poller_mactrack.php'");
+ });
+});
diff --git a/tests/Unit/UnserializeHardeningTest.php b/tests/Unit/UnserializeHardeningTest.php
new file mode 100644
index 00000000..6808b10b
--- /dev/null
+++ b/tests/Unit/UnserializeHardeningTest.php
@@ -0,0 +1,43 @@
+\s*false\]\s*\)/",
+ $macs,
+ $safeMatches
+ );
+ $bare = preg_match_all(
+ "/unserialize\s*\(\s*get_nfilter_request_var\s*\([^)]+\)\s*\)/",
+ $macs,
+ $bareMatches
+ );
+ expect($safe)->toBeGreaterThanOrEqual(1, 'at least one safe unserialize with allowed_classes:false');
+ expect($bare)->toBe(0, 'no bare unserialize of user input without allowed_classes');
+ });
+
+ it('does not have any bare unserialize of get_nfilter_request_var across the codebase', function () {
+ $phpFiles = glob(realpath(__DIR__ . '/../../') . '/*.php');
+ $phpFiles = array_merge($phpFiles, glob(realpath(__DIR__ . '/../../lib/') . '/*.php'));
+
+ $violations = [];
+
+ foreach ($phpFiles as $file) {
+ $source = file_get_contents($file);
+ if (preg_match('/unserialize\s*\(\s*get_nfilter_request_var/', $source) &&
+ !preg_match("/unserialize\s*\([^,]+,\s*\['allowed_classes'\s*=>\s*false\]/", $source)) {
+ $violations[] = basename($file);
+ }
+ }
+
+ expect($violations)->toBeEmpty('these files have unsafe unserialize: ' . implode(', ', $violations));
+ });
+
+ it('uses sanitize_unserialize_selected_items for integer id arrays', function () use ($macs) {
+ expect($macs)->toContain('sanitize_unserialize_selected_items');
+ });
+});
diff --git a/tests/Unit/XssEscapingTest.php b/tests/Unit/XssEscapingTest.php
new file mode 100644
index 00000000..1c7264a2
--- /dev/null
+++ b/tests/Unit/XssEscapingTest.php
@@ -0,0 +1,76 @@
+toContain("html_escape(\$site['site_name'])");
+ expect($arp)->not->toContain("'>' . \$site['site_name'] . ''");
+ });
+
+ it('escapes device_name and hostname in mactrack_view_arp.php select option', function () use ($arp) {
+ expect($arp)->toContain("html_escape(\$filter_device['device_name'])");
+ expect($arp)->toContain("html_escape(\$filter_device['hostname'])");
+ expect($arp)->not->toContain("'>' . \$filter_device['device_name']");
+ });
+
+ it('escapes site_name in mactrack_view_macs.php select option', function () use ($macs) {
+ expect($macs)->toContain("html_escape(\$site['site_name'])");
+ expect($macs)->not->toContain("'>' . \$site['site_name'] . ''");
+ });
+
+ it('escapes device_name and hostname in mactrack_view_macs.php select option', function () use ($macs) {
+ expect($macs)->toContain("html_escape(\$filter_device['device_name'])");
+ expect($macs)->toContain("html_escape(\$filter_device['hostname'])");
+ });
+
+ it('escapes mac address list entry in mactrack_view_macs.php', function () use ($macs) {
+ expect($macs)->toContain('html_escape(mactrack_format_mac($mac))');
+ expect($macs)->not->toMatch("/'\\.\\s*mactrack_format_mac\\(\\\\\\$mac\\)\\s*\\.'<\\/li>'/");
+ });
+
+ it('escapes site_name in mactrack_view_ips.php', function () use ($ips) {
+ expect($ips)->toContain("html_escape(\$site['site_name'])");
+ });
+
+ it('escapes site_name in mactrack_view_interfaces.php', function () use ($iface) {
+ expect($iface)->toContain("html_escape(\$site['site_name'])");
+ });
+
+ it('escapes device_name in mactrack_view_interfaces.php', function () use ($iface) {
+ expect($iface)->toContain('html_escape($device_name)');
+ });
+
+ it('escapes site_name in mactrack_view_dot1x.php', function () use ($dot1x) {
+ expect($dot1x)->toContain("html_escape(\$site['site_name'])");
+ });
+
+ it('escapes device_name in mactrack_devices.php', function () use ($devs) {
+ expect($devs)->toContain("html_escape(\$device['device_name'])");
+ });
+
+ it('escapes hostname in mactrack_devices.php', function () use ($devs) {
+ expect($devs)->toContain("html_escape(\$device['hostname'])");
+ });
+
+ it('escapes site_name in mactrack_devices.php select option', function () use ($devs) {
+ expect($devs)->toContain("html_escape(\$site['site_name'])");
+ });
+
+ it('escapes site_name in lib/mactrack_functions.php', function () use ($funcs) {
+ expect($funcs)->toContain("html_escape(\$site['site_name'])");
+ });
+
+ it('escapes device_type description and sysDescr_match in lib/mactrack_functions.php', function () use ($funcs) {
+ expect($funcs)->toContain("html_escape(\$device_type['description'])");
+ expect($funcs)->toContain("html_escape(\$device_type['sysDescr_match'])");
+ });
+});
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 00000000..a075e1e8
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,5 @@
+ |