Files
cacti/tests/security/baselines/architectural_hotspots.baseline.tsv
T
TheWitnessandTheWitness 3ab4c69fb7 PHPStan Level 8 - Phase 1 Conversion (#8135)
* PHPStan Level 8 - Phase 1 Conversion

Reduces the real (no-baseline) PHPStan level 8 error count from ~2,724 to 425
and regenerates phpstan-baseline.neon accordingly (8,941 -> 1,921 lines).

Core type-safety changes
- lib/database.php: narrow db_fetch_row()/db_fetch_row_prepared() to return
  array (was bool|array) and db_fetch_assoc()/db_fetch_assoc_prepared() to
  return array (was mixed); the _prepared workers coerce the connection-failure
  path to an empty array so the declared type is honest. empty()/cacti_sizeof()
  checks are unaffected.
- lib/database.php: narrow $db_conn guards from is_object() to instanceof PDO in
  the transaction/quote/insert-id helpers so the PDO calls resolve.
- lib/mib_cache.php: the fluent MibCache methods mib()/object()/table()/row()
  now return self instead of a bare object so chained calls type-check.

Type-safety casts and guards (mechanical, behaviour-preserving)
- Cast string|false/|null builtin results feeding string parameters
  (json_decode, base64_encode, trim, htmle, preg_replace, explode, etc.).
- Cast int|string / int|false values feeding int id/pid parameters.
- Guard resource|false fopen()/popen() handles before fwrite/fclose/fread, and
  wrap foreach over array|false/list|false results with `?: []`.

Genuine bugs found and fixed
- cli/add_tree.php / lib/api_tree.php: site tree nodes could not be created via
  the CLI - the --site-id option was never parsed, $nodeTypes had no 'site'
  entry (passing null to api_tree_item_save(int $type) -> TypeError), and
  api_tree_item_save() never persisted site_id. All three fixed.
- tree.php: leaves_exist() is typed : int but returned db_fetch_assoc_prepared()
  (an array of rows) for a SELECT COUNT(*); now uses db_fetch_cell_prepared()
  and returns the scalar count.
- lib/mib_cache.php: $entries_per_object was a float (division / ceil) used as
  an array key (PHP 8.1 implicit float-to-int key deprecation); now cast to int.

Config
- .phpstan.neon: add notIdentical.alwaysTrue and booleanAnd.alwaysFalse to the
  existing always-true/false ignore family (same "PHPStan gets confused"
  dynamic-constant cause already covered by identical.alwaysTrue,
  notEqual.alwaysTrue, booleanAnd.leftAlwaysTrue, etc.).
- phpstan-baseline.neon: regenerated; the remaining 425 entries are the
  untouched long tail for Phase 2.

* Remove redundant @phpstan-ignore comments

Follow-up cleanup for the level 8 Phase 1 work. Removes 14 inline
@phpstan-ignore comments that no longer suppress any reported error:

- cli/repair_database.php: `@phpstan-ignore booleanAnd.alwaysFalse` is now
  covered by the global booleanAnd.alwaysFalse ignore added to .phpstan.neon.
- 13 others (host.php, include/global.php, include/session.php,
  lib/api_automation.php x2, lib/functions.php x2, lib/html_utility.php x2,
  lib/installer.php, lib/package.php, lib/reports.php, lib/rrd.php) no longer
  match any reported error after the type-safety fixes (e.g. the
  db_fetch_row_prepared() return-type narrowing removed the offset-assign
  errors the api_automation ignores were suppressing).

Verified with reportUnmatchedIgnoredErrors enabled: 0 unmatched ignores
remain and the full level 8 run still reports 0 errors.

* ci: satisfy validator ratchet and refresh security sink baselines

- lib/api_tree.php: give the new tree-item site_id a real numeric
  constraint ('^[-0-9]+$', matching html_reports.php) instead of an
  empty regex, so the validator constraint ratchet passes without
  bumping its baseline.
- tests/security/baselines: refresh the three sink/hotspot signatures
  whose code text changed from the (string) casts added in this branch
  (lib/poller.php setsid, cli/rrdresize.php json_encode, lib/import.php
  simplexml_load_string).

* ci(js): allow DOMPurify 3.4.16 and update sync_js pin test

The dependabot bump (#8134) moved package.json/package-lock.json to
DOMPurify 3.4.16 on develop but left tests/js/sync_js.test.mjs pinned to
3.4.15, so the PR merge commit fails the 'DOMPurify is pinned' contract.
Bring package.json/package-lock.json to 3.4.16 (matching develop) and
update the pin test to expect 3.4.16.

* test: update sql.php null-handling assertion for the (string) cast

This branch changed scripts/sql.php from trim(\ ?? '') to
trim((string) \) (the phpstan-safe form that coerces both null and
false from shell_exec()). Update SqlScriptsTest's null-return assertion
to match the new cast instead of the removed '?? ''.

* fix: address Copilot review - ownership IDs, fopen guards, mib_cache bound

- chown()/chgrp(): pass integer UID/GID from fileowner()/filegroup()
  (guarding the false lookup) instead of string-casting them, which made
  them resolve account/group names like '1001' and fail. Covers
  lib/api_device.php, lib/boost.php and poller_maintenance.php (x2).
- fopen() failure now short-circuits instead of reporting success:
  lib/package.php returns false, lib/clog_webapi.php and utilities.php
  render an error, and snmpagent_mibcachechild.php skips the cache write
  and sentinel unlink so a concurrent writer's lock is preserved.
- lib/mib_cache.php: keep the float quotient as the loop bound and cast
  only the array offset, so a non-even entries/columns split no longer
  truncates the loop and drops a cached row.

* revert: keep db_fetch_* returning false on error; baseline Level 8 instead

Per review, restoring the intentional fail-safe: db_fetch_row[_prepared]()
return bool|array and db_fetch_assoc[_prepared]() return mixed again, so a
genuine SQL error (not just a missing connection) is signalled as false and
the === false checks in cdef_is_in_use(), boost_process_* and aggregate.php
work as before. Dropped the is_array() ? : [] coercion.

Level 8 stays green by regenerating phpstan-baseline.neon to capture the
re-introduced bool|array/mixed call-site errors (the ~1,850 the narrowing
had eliminated). Tightening those callers to drop them from the baseline is
Phase 2 work.

---------

Co-authored-by: TheWitness <copilot@users.noreply.github.com>
2026-10-02 10:08:56 -04:00

14 KiB

1classlocationmatch
2cmd_exec./cactid.phpexec('pgrep -a php | grep cactid.php', $output);
3cmd_exec./cli/audit_database.phpexec('php ' . CACTI_PATH_PLUGINS . '/' . $pname . '/database_upgrade.php --type=large --force-ver=' . $old, $output, $return_var);
4cmd_exec./cli/audit_database.php$db_shell = trim((string) shell_exec('which mysql'));
5cmd_exec./cli/audit_database.phpexec($cmd, $output, $error);
6cmd_exec./cli/audit_database.phpexec('php ' . CACTI_PATH_CLI . '/upgrade_database.php --debug', $output, $return_var);
7cmd_exec./cli/batchgapfix.phpexec($command, $output, $return_var);
8cmd_exec./cli/float_rrdfiles.php$response = exec($command, $output, $return);
9cmd_exec./cli/float_rrdfiles.php$response = exec($command, $output, $return);
10cmd_exec./cli/push_out_hosts.phppassthru($php_binary . ' ' . CACTI_PATH_CLI . '/rebuild_poller_cache.php ' . $parameters);
11cmd_exec./cli/remove_broken_graphs.php$stty = shell_exec('stty size');
12cmd_exec./cli/rrdresize.php$process = proc_open($path_rrdtool . ' -', $fds, $pipes);
13cmd_exec./cli/splice_rrd.php$result = exec($command, $output, $return_var);
14cmd_exec./cli/splice_rrd.php$response = shell_exec(cacti_escapeshellcmd($rrdtool));
15cmd_exec./cli/splice_rrd.phpshell_exec(cacti_escapeshellcmd($rrdtool) . ' dump ' . cacti_escapeshellarg($newrrd) . ' > ' . cacti_escapeshellarg($newxmlfile));
16cmd_exec./cli/splice_rrd.phpshell_exec(cacti_escapeshellcmd($rrdtool) . ' dump ' . cacti_escapeshellarg($oldrrd) . ' > ' . cacti_escapeshellarg($oldxmlfile));
17cmd_exec./cli/update_heartbeat.php$result = exec($command, $output, $return_code);
18cmd_exec./cmd.php$cactiphp = proc_open($command, $cactides, $pipes);
19cmd_exec./cmd_realtime.php$cactiphp = proc_open(read_config_option('path_php_binary') . ' -q ' . CACTI_PATH_BASE . '/script_server.php realtime ' . cacti_escapeshellarg($poller_id), $cactides, $pipes);
20cmd_exec./graph_realtime.phpshell_exec($php_binary . ' -q ' . $script_path . ' ' . $args);
21cmd_exec./host.phpshell_exec(cacti_escapeshellcmd((string) read_config_option('path_php_binary')) . ' -q ' . cacti_escapeshellarg(CACTI_PATH_CLI . '/poller_reindex_hosts.php') . ' --qid=all --id=' . $host_id);
22cmd_exec./lib/dsstats.php$process = proc_open($command, $fds, $pipes);
23cmd_exec./lib/functions.php$output = shell_exec($script_path);
24cmd_exec./lib/functions.php$output = shell_exec($script_path);
25cmd_exec./lib/functions.php$shell = shell_exec(cacti_escapeshellcmd(read_config_option('path_rrdtool')) . ' -v 2>&1');
26cmd_exec./lib/functions.php$shell = shell_exec(cacti_escapeshellcmd(read_config_option('path_rrdtool')) . ' -v');
27cmd_exec./lib/functions.phpshell_exec($killCmd);
28cmd_exec./lib/functions.php$cpu_cores = shell_exec('/bin/nproc');
29cmd_exec./lib/functions.php$cpu_cores = shell_exec('/usr/bin/nproc');
30cmd_exec./lib/functions.php$output = shell_exec('nproc');
31cmd_exec./lib/functions.phpexec('id -nu', $o, $r);
32cmd_exec./lib/functions.php$cpu_cores = shell_exec('sysctl -n hw.ncpu');
33cmd_exec./lib/functions.php$processExists = trim((string) shell_exec($checkProcessCmd));
34cmd_exec./lib/functions.phpexec($command_line, $out, $return_code);
35cmd_exec./lib/functions.phpreturn exec('whoami') ?: '';
36cmd_exec./lib/functions.php * On Windows, exec()/shell_exec()/popen() route through cmd.exe, which ignores
37cmd_exec./lib/installer.php$output = shell_exec(
38cmd_exec./lib/installer.php$output = shell_exec(cacti_escapeshellcmd(str_replace('\\', '/', $path)) . ' --version 2>&1');
39cmd_exec./lib/installer.php$process = proc_open(
40cmd_exec./lib/ping.php$result = shell_exec('ping -6 -W ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' -p ' . $pattern . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
41cmd_exec./lib/ping.php$result = shell_exec('ping -W ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' -p ' . $pattern . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true) . ' 2>&1');
42cmd_exec./lib/ping.php$result = shell_exec('ping -t ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
43cmd_exec./lib/ping.php$result = shell_exec('ping6 -t ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
44cmd_exec./lib/ping.php$result = shell_exec('chcp 437 && ping -w ' . $this->timeout . ' -n ' . $this->retries . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
45cmd_exec./lib/ping.php$result = shell_exec('ping ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
46cmd_exec./lib/ping.php$result = shell_exec('ping -i ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
47cmd_exec./lib/ping.php$result = shell_exec('ping -m ' . ceil($this->timeout / 1000) . ' -n ' . $this->retries . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
48cmd_exec./lib/ping.php$result = shell_exec('ping -t ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
49cmd_exec./lib/ping.php$result = shell_exec('ping -t ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
50cmd_exec./lib/ping.php$result = shell_exec('ping -w ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
51cmd_exec./lib/ping.php$result = shell_exec(cacti_escapeshellarg($fping) . ' -q -t ' . $this->timeout . ' -c 1 -r ' . $this->retries . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true) . ' 2>&1');
52cmd_exec./lib/poller.php$output = system($php_path . ' -l ' . $tmpfile, $exit);
53cmd_exec./lib/poller.php$fp = popen($command, 'r');
54cmd_exec./lib/poller.php$fp = popen($command, 'rb');
55cmd_exec./lib/poller.phpexec($safe_filename . ' ' . $args . ' ' . $redirect_args . ' &');
56cmd_exec./lib/poller.phpexec($safe_filename . ' ' . $args . ' > /dev/null 2>&1 &');
57cmd_exec./lib/poller.php$output = shell_exec($command);
58cmd_exec./lib/poller.php$setsid_path = trim((string) shell_exec('which setsid 2>/dev/null'));
59cmd_exec./lib/poller.php$process = @proc_open($command, $descriptors, $pipes, null, null, ['bypass_shell' => true]);
60cmd_exec./lib/poller.php$process = proc_open('exec ' . $cmd_full, $descriptors, $pipes);
61cmd_exec./lib/poller.php * of the proc_open() and proc_close() functions (php 4.3+)
62cmd_exec./lib/poller.php * @param array $pipes The array of r/w pipes returned from proc_open()
63cmd_exec./lib/poller.php * @param mixed $proc_fd The file descriptor returned from proc_open()
64cmd_exec./lib/rrd.php$process = proc_open([$path, '-'], $descriptors, $pipes, null, null, ['bypass_shell' => true]);
65cmd_exec./lib/rrdcheck.php$process = proc_open($command, $fds, $pipes);
66cmd_exec./lib/snmp.php// exec() does not capture, so the walk output can never contain either.
67cmd_exec./lib/snmp.phpexec($command, $snmp_value, $return_var);
68cmd_exec./lib/snmp.phpexec($command, $snmp_value, $return_var);
69cmd_exec./lib/snmp.phpexec($command, $snmp_value, $return_var);
70cmd_exec./lib/snmpagent.php// add pollers of a distributed system (future)
71cmd_exec./lib/spikekill.php$dump_output = shell_exec(cacti_escapeshellcmd(read_config_option('path_rrdtool')) . ' dump ' . cacti_escapeshellarg($this->rrdfile) . ' 2>&1 > ' . cacti_escapeshellarg($xmlfile));
72cmd_exec./lib/spikekill.php$response = shell_exec(cacti_escapeshellcmd(read_config_option('path_rrdtool')) . ' restore -f -r ' . cacti_escapeshellarg($xmlfile) . ' ' . cacti_escapeshellarg($rrdfile));
73cmd_exec./lib/utility.php$nproc = system('nproc');
74cmd_exec./lib/utility.phpexec('/usr/bin/free', $output, $exit_code);
75cmd_exec./lib/utility.phpexec('wmic os get FreePhysicalMemory', $memInfo['FreePhysicalMemory']);
76cmd_exec./lib/utility.phpexec('wmic os get FreeSpaceInPagingFiles', $memInfo['FreeSpaceInPagingFiles']);
77cmd_exec./lib/utility.phpexec('wmic os get FreeVirtualMemory', $memInfo['FreeVirtualMemory']);
78cmd_exec./lib/utility.phpexec('wmic os get SizeStoredInPagingFiles', $memInfo['SizeStoredInPagingFiles']);
79cmd_exec./lib/utility.phpexec('wmic os get TotalVirtualMemorySize', $memInfo['TotalVirtualMemorySize']);
80cmd_exec./lib/utility.phpexec('wmic os get TotalVisibleMemorySize', $memInfo['TotalVisibleMemorySize']);
81cmd_exec./lib/utility.php$json = shell_exec($php . ' -q ' . $php_file);
82cmd_exec./lib/utility.php$json = shell_exec($php . ' -q ' . $php_file);
83cmd_exec./lib/utility.php$json = shell_exec($php . ' -q ' . $php_file);
84cmd_exec./poller_spikekill.php$response = exec(cacti_escapeshellcmd((string) read_config_option('path_php_binary')) . ' -q ' .
85cmd_exec./remote_agent.php$cactiphp = proc_open(read_config_option('path_php_binary') . ' -q ' . CACTI_PATH_BASE . '/script_server.php realtime ' . cacti_escapeshellarg($poller_id), $cactides, $pipes);
86cmd_exec./scripts/sql.php$sql = shell_exec($cmd);
87cmd_exec./scripts/ss_netsnmp_lmsensors.php$virtual = intval(shell_exec('/usr/bin/hostnamectl 2> /dev/null | grep -i virtual | wc -l'));
88cmd_exec./scripts/ss_sql.php$result = shell_exec($cmd) ?? '';
89cmd_exec./snmpagent_mibcache.phpexec($php . ' ' . $extra_args . ' > /dev/null &');
90cmd_exec./snmpagent_persist.phpexec($php . ' ' . $extra_args . ' > /dev/null &');
91cmd_exec./support.php$out = shell_exec(cacti_escapeshellarg($path) . ' ' . $arg . ' 2>&1');
92cmd_exec./support.phpprint '<td>' . tech_env_status(DB_STATUS_WARNING, __('Version unavailable: shell_exec() is disabled')) . '</td>';
93cmd_exec./support.phpexec(cacti_escapeshellcmd(read_config_option('path_rrdtool')), $out_array);
94cmd_exec./support.php$cli_json = shell_exec(cacti_escapeshellcmd($php_binary) . ' -q ' . cacti_escapeshellarg(CACTI_PATH_INSTALL . '/cli_check.php') . ' extensions');
95cmd_exec./support.php$snmp_version = (string) shell_exec(cacti_escapeshellcmd($snmpget_path) . ' -V 2>&1');
96cmd_exec./support.phpexec(cacti_escapeshellcmd(read_config_option('path_spine')) . ' --version', $out_array);
97dynamic_include./cli/audit_database.phprequire_once($plugin . '/includes/database.php');
98dynamic_include./cli/audit_database.phprequire_once($plugin . '/setup.php');
99dynamic_include./cli/upgrade_database.phpinclude($upgrade_file);
100dynamic_include./include/global.phprequire_once($vendor_autoload);
101dynamic_include./include/global_languages.phprequire_once($providerFull);
102dynamic_include./index.phprequire_once($file);
103dynamic_include./lib/installer.phpinclude_once($upgrade_file);
104dynamic_include./lib/plugins.phpinclude_once($full_path);
105dynamic_include./lib/plugins.phprequire_once($setup_file);
106dynamic_include./lib/renderer.phpinclude $__cacti_template_file;
107dynamic_include./lib/rrd.phpinclude($rrdtheme);
108dynamic_include./lib/rrd.phpinclude($themefile);
109dynamic_include./link.phprequire_once($file);
110dynamic_include./plugins.phprequire_once($setup_file);
111dynamic_include./reports.phprequire_once($reportit_api);
112dynamic_include./script_server.phprequire_once($include_file);
113dynamic_include./scripts/ss_mikrotik_snmpget.phpinclude_once($config['base_path'] . '/lib/snmp.php');
114dynamic_include./scripts/ss_netsnmp_lmsensors.phpinclude($config['base_path'] . '/lib/snmp.php');
115dynamic_include./snmpagent_persist.phprequire($path_mibcache);
116host_header./lib/auth.php$result['link'] = \Sonata\GoogleAuthenticator\GoogleQrUrl::generate($current_user['username'] . '@' . $_SERVER['HTTP_HOST'], $current_user['tfa_secret'], 'Cacti');