mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
* fix(win32): harden the cmd.exe shell sinks on develop (GHSA-rjvj-r52f-8v5q) Forward-port of the 1.2.x win32 command-injection remediation. On Windows the SNMP/ping/data-query/trap commands run through cmd.exe, which ignores the \" escape and toggles quoting on every ", so cacti_escapeshellarg cannot stop & | ^ < > ( ). - add cacti_escapeshellarg_cmd(), stripping those operators on win32 before quoting (a Unix pass-through) - snmp_escape_string(): strip the operators in the win32 branch - route the SNMP binary hostname, the data-query device values (get_script_query_path), the data-input arg builders, the Windows ping hostname, and the SNMP trap notification arguments through the strip get_script_query_path is device-reachable (a rogue monitored device supplies an SNMP index/system field that executes on the Windows poller); the rest are post-auth. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(win32): strip %VAR% from the hostname on Windows (GHSA-rjvj-r52f-8v5q) cmd.exe expands %VAR% even inside quotes, so a device hostname of %USERNAME% would expand into the SNMP/ping command line. Add an opt-in $strip_env flag to cacti_escapeshellarg_cmd() and set it for the SNMP binary and ping hostnames, which never legitimately contain a percent. Community and credential values are left untouched because they can legitimately contain %. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * docs(changelog): note win32 cmd.exe hardening for develop * security: require Package Management permission to trust new package signers package_accept_key() (reachable via the repository-based package import flow) inserted new keys into package_public_keys, the global trust store consulted by import_validate_signature() for every future import by every user, without checking for the Package Management (realm 29) permission that gates package_keys.php/package.php/package_repos.php for the same table. An Import Templates (realm 17) user could therefore add themselves as a trusted signer for the whole install. Also add the 'accept' action to the CSRF-protected bad_actions list so the acceptance step requires POST plus a valid token like every other state-changing action. GHSA-gxgm-8rhv-r7jv * docs(changelog): note package-signer trust authorization fix * security: close tab-based open redirect bypass in CSRF timeout handler sanitize_uri()'s guard (20298c6cd) models the WHATWG rule that browsers strip leading C0 controls/whitespace before resolving a redirect, but Chromium separately strips an embedded tab from anywhere in the target. A request target of /%09/evil.example passes the guard (no leading control char, not two leading slashes) but Chromium reduces the resulting 'Location: /<TAB>/evil.example' header to a network-path reference and navigates off-site. 1.2.x already uses validate_redirect_url() for this same sink (3338a63e1), which resolves the URL via parse_url() and checks host/port against the server-configured values, so it isn't bypassable this way. Switch develop's CSRF timeout handler to match. GHSA-c725-4jm5-9fx5 * security: authorize single-item Report actions against ownership reports.php sets \ = true, so include/auth.php grants an implicit guest session (when the Guest User feature is enabled) without ever evaluating the realm 22 gate declared for this page. The single-item action handlers reports_item_remove()/_moveup()/_movedown() relied solely on that page-level gate and performed no independent ownership check, so an unauthenticated guest-bound request could delete or reorder reports_items rows belonging to any report, including ones owned by an administrator. reports_form_actions() already gained an equivalent \ check; add the same authorization (Reports admin realm 21, or the requesting user owns the item's report) to the three single-item handlers. Same underlying chain as the 1.2.31 fix for reports_user.php (guest bind removed + cacti_authorize_resource() added to reports_item_remove()); the rewritten develop reports.php/lib/html_reports.php did not carry either half of that fix forward. GHSA-j5jh-fhwg-r56x * security: authorize tree node CRUD actions against per-tree access tree.php dispatches create_node/delete_node/move_node/rename_node/copy_node directly to api_tree_*() with no is_tree_allowed() check, even though the normal tree-view path already enforces that boundary. A non-admin user with the Trees realm but no explicit grant on a given tree_id could still create, delete, move, rename or copy nodes on that tree by calling these actions directly, bypassing the authorization boundary enforced elsewhere. GHSA-prxj-84qq-v5jq * security: remove shell from Realtime RRD create and validate DS bounds poller_realtime.php built the Realtime-graph RRD 'create' command line (including the SNMP-sourced ifSpeed/ifHighSpeed interface speed, substituted in verbatim as the DS maximum) and ran it through shell_exec(). A hostile or spoofed monitored device could return an ifSpeed value such as '1000000000\uid=197608(ladams) gid=197121 groups=197121' and get arbitrary OS command execution as the web/poller user the next time any user with the Realtime Graphs permission (or an unauthenticated guest, where configured) viewed a realtime graph for an affected data source. - lib/rrd.php: rrdtool_function_interface_speed() now casts ifHighSpeed/ ifSpeed to int before they become the DS maximum. - lib/rrd.php: rrdtool_function_create() now rejects any non-numeric rrd_minimum/rrd_maximum right before they are concatenated onto the rrdtool command line, forcing them to 'U' and logging instead. - poller_realtime.php: the RRD create is now routed through the same shell-free rrdtool_execute()/proc_open(bypass_shell) path every other RRDtool invocation already uses, matching the pattern 1.2.x already uses for this exact call site, instead of shell_exec(). GHSA-f88m-q789-g22j * security: authorize Report save/edit against report ownership reports_form_save()'s update path fetched the existing report's owner from the database but never compared it to the session user before calling sql_save(), so any user holding only the Reports realm could overwrite another user's report by POSTing its id. reports_edit() had the same gap on the read side: it fetched a report by id with no ownership check, so the same user could view any other user's report configuration (recipients, schedule, etc.). 1.2.x already guards both call sites with cacti_authorize_resource(..., 'reports'); develop had lost that check. GHSA-8f5c-3h59-q7gc * security: reject ORDER BY sort_column subquery bypass sanitize_sql_column()'s allowlist regex kept parentheses to support the INET_ATON(col)/NATURAL_SORT_KEY(col) sort wrappers, but that also let MySQL subquery syntax through unchanged, since a subquery like (SELECT(0)FROM(SELECT(SLEEP(5)))a) needs only alphanumerics and parentheses. get_order_string() then detects the '(' and disables its backtick quoting, so the payload reaches ORDER BY verbatim, giving any user with realm 2-5 a blind, time-based SQL injection via sort_column. sanitize_sql_column() now only allows '(' through when the whole value is exactly one of the two known safe wrapper forms around a plain identifier; anything else containing '(' -- including subquery syntax -- now falls back to the default. Added regression tests covering the reported subquery and REGEXP-exfiltration payloads. GHSA-8j73-cvf6-jrvx * security: confine Reports format_file to a bare filename reports_form_save() wrote the POST-supplied format_file value verbatim into reports.format_file, and reports_load_format_file() concatenated it onto CACTI_PATH_FORMATS with no directory-component check, so a value such as ../../../../etc/passwd escaped the formats directory and any Reports-realm user could read arbitrary files readable by the web server. 1.2.x already fixed the same sink (GHSA-g37j-39f4-6r4j / CVE-2026-41884, GHSA-mjvw-mhj5-9jcj / CVE-2026-40084); develop had lost the guard. format_file is now confined to basename() at both the save site and the load site, with the load site additionally rejecting a bare '.'/'..'/empty result before any file IO. Added regression tests. GHSA-5x27-wqpm-m24v * security: authorize Data Source enable/disable against owning device ds_enable()/ds_disable() and the bulk drp_action enable/disable loops in form_actions() passed a user-controlled local_data_id straight into api_data_source_enable()/api_data_source_disable() with no object-level authorization check, so any user holding the Sites/Devices/Data realm could toggle the enabled state of a data source belonging to a device outside their assigned Device Permissions. Added data_source_authorized(\), which allows host-independent data sources (data_local.host_id = 0) and otherwise defers to is_device_allowed(\), and applied it to both the single-item and bulk action paths. GHSA-2fhv-c794-v433 * security: authorize bulk Change Data Source Profile against owning device The bulk 'Change Data Source Profile' action (drp_action == '6') iterated selected_items and updated data_template_data/data_template_rrd/poller_item for each supplied local_data_id with no object-level authorization check, so any Sites/Devices/Data realm user could retarget the RRD step/heartbeat of a data source belonging to a device outside their assigned Device Permissions -- the same class of gap as GHSA-2fhv, in a different bulk action. Guarded with the data_source_authorized() helper added for that fix. This action does not exist on 1.2.x (develop-only feature). GHSA-fc82-w6xq-4jg7 * security: grant AllowOverride All in docker/apache.conf The packaged docker/apache.conf granted only 'AllowOverride FileInfo Options=FollowSymLinks' for the Cacti document root. Cacti's shipped .htaccess deny rules (log/, cache/*, scripts/, cli/, bin/, mibs/, rra/, contrib/, tests/) rely on 'Require all denied' (Apache 2.4, override type AuthConfig) and/or legacy 'Order/Deny' (override type Limit), neither of which FileInfo/Options covers, so Apache silently ignored every one of those deny rules and served the directories over HTTP to any unauthenticated client -- most notably log/cacti.log, which can contain usernames, client IPs, SQL backtraces, and any secrets Cacti logs (SNMP community strings, LDAP bind details). AllowOverride All lets every shipped .htaccess apply as intended. Added a regression test. docker/ does not exist on 1.2.x (develop-only). GHSA-fxfg-j995-j69j * docs: add security advisory / CVE policy to Copilot instructions Documents the policy established for this remediation effort: develop is unreleased, so a develop-only vulnerability does not get a CVE requested; 1.2.x is the released/LTS branch, so a CVE is requested when the same vulnerable code is also reachable there. Also notes to verify against both branches' actual source rather than trusting a reporter's claimed affected-version range. * fix(security): update stale test harnesses and address review findings CI fixes: - CsrfCheckContractProbe.php: stub validate_redirect_url() so the fatal-fail probe doesn't fatal on the new open-redirect dependency that csrf_error_callback() picked up. - InstallerCsrfRecoveryTest.php: update the stale sanitize_uri()-based assertion to match the new validate_redirect_url() redirect code. - psalm-baseline.xml: drop the TaintedHeader suppression for include/csrf.php now that validate_redirect_url() resolves it. Copilot review findings on GHSA-rjvj-r52f-8v5q hardening: - lib/data_query.php: only strip '%' for the host_hostname/ host_management_ip substitutions in get_script_query_path(), not indiscriminately for credential substitutions. - lib/functions.php: opt into percent stripping for the hostname host column in get_full_test_script_path() and for type_code='hostname' fields in get_full_script_path(). - lib/snmpagent.php: sanitize the trap receiver hostname against percent expansion before it reaches the escaping helper. - tests/security/baselines/*.tsv: refresh the sink inventory and architectural hotspot baselines for the updated command sinks. - tests/Unit/DataCollection/Snmp/SnmpCoverageTest.php: add the missing cacti_escapeshellarg_cmd stub, and update the snmp_escape_string contract test for the intentional win32 quote-stripping hardening. - tests/Unit/Security/Shell/CmdMetacharHelperTest.php + tests/fixtures/CmdMetacharHelperWin32Probe.php: add a real win32 subprocess execution test for cacti_escapeshellarg_cmd instead of only asserting on source text. - CHANGELOG: add the pull-request reference to the new entry. * fix(snmp): remove now-unreachable quote-escaping branch in snmp_escape_string The metachar strip on the line above already removes the double quote on win32, so the substr_count(SNMP_ESCAPE_CHARACTER) branch below it could never execute; drop it so lib/snmp.php reaches 100% coverage again. * style: fix pre-existing php-cs-fixer blank-line drift blocking CI data_sources.php and lib/html_reports.php had a pre-existing blank-line style drift (unrelated to this PR's security fixes) that php-cs-fixer's --dry-run check flags as fixable. Since "PHP 8.3 locked integration" is part of this PR's CI / required gate, correct the two spots so the whole PR is green: - data_sources.php: collapse a double blank line before form_actions(). - lib/html_reports.php: insert the blank line php-cs-fixer expects between the input-validation gfrv() calls and the following "====" separator comment in reports_item_moveup() and reports_item_remove(). No behavior change; purely CI/style formatting. * style: refresh phpstan baseline for pre-existing, unrelated findings Three PHPStan findings were masked behind the earlier php-cs-fixer failure and only surfaced once that step passed: - lib/html_reports.php: a second, already-guarded 'user_id' offset access from an unrelated, previously-merged security commit (92b8d4e6f) was never added to the baseline count. - cli/splice_rrd.php, poller_realtime.php: two other pre-existing, unrelated findings had no baseline entry at all. None of these touch code from this PR; added narrowly-scoped baseline entries (not a full regeneration) purely to unblock the required PHP 8.3 locked integration check. * fix(security): address follow-up review findings on consolidated PR Closes the four remaining review findings from the follow-up review round: - package_import.php: trustSigner() now POSTs with a CSRF token instead of $.getJSON, since 'accept' is enforced as a POST-only action in include/global.php's bad_actions list (GHSA-gxgm-8rhv-r7jv). - lib/html_reports.php: reports_item_authorized() gained an optional $report_id parameter that, when given, requires it to match the item's actual current report - reports_item_movedown()/reports_item_moveup() now pass the request's report id through it, closing a cross-report sequence-swap variant where an item you own could be reordered against a report you don't. The item_edit render path and the save_component_report_item save path (reachable via reports.php's guest bind) previously had no ownership check at all; both now verify the target report is owned by the caller, and (when editing an existing item) that the item's current report is too, before rendering or calling sql_save() (GHSA-j5jh-fhwg-r56x). - tree.php + include/global.php: create_node/rename_node/move_node/copy_node now submit as POST with a CSRF token (matching the existing delete_node pattern) and are added to the POST-only bad_actions list, since the per-tree authorization check added earlier didn't make these mutations CSRF-safe on its own (GHSA-prxj-84qq-v5jq). Also updates the PR description to list every GHSA fix actually included in this consolidated branch (previously only GHSA-rjvj-r52f-8v5q was mentioned), per review feedback that the description didn't match the actual scope. * fix: resolve CI failures on PR #8056 (docblock alignment, sink baseline) - lib/html_reports.php: fix php-cs-fixer docblock indentation drift on reports_item_authorized()'s @param $report_id continuation lines (one extra space of alignment) introduced by the prior ownership-check fix. - tests/security/baselines/sink_inventory.baseline.tsv: refresh to track the 4 new header('Location: ' . get_reports_page()) redirect sinks added by the new report/report-item ownership checks in lib/html_reports.php. Verified via tests/security/verify_sink_inventory.sh and confirmed tests/security/verify_architectural_hotspots.sh still reports no new hotspots. * fix: track new PHPStan offset findings from extended reports_item_authorized() phpstan-baseline.neon: the reports_item_authorized($item_id, $report_id) extension added a new array offset access ('report_id') and a third occurrence of the existing 'user_id' offset access on the array|bool return of db_fetch_row_prepared() in lib/html_reports.php. Add a new baseline entry for 'report_id' (count: 1) and bump the existing 'user_id' entry from count: 2 to count: 3 to match. Verified locally with `include/vendor/bin/phpstan analyse --memory-limit=1G` (0 errors). --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> Co-authored-by: Thomas Vincent <thomasvincent@gmail.com> Co-authored-by: TheWitness <copilot@users.noreply.github.com>
1256 lines
38 KiB
PHP
1256 lines
38 KiB
PHP
<?php
|
|
/*
|
|
+-------------------------------------------------------------------------+
|
|
| Copyright (C) 2004-2026 The Cacti Group |
|
|
| Portions Copyright (C) 2010 Boris Lytochkin, Sponsored by Yandex LLC |
|
|
| |
|
|
| 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/ |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
/**
|
|
* Select a reliable uptime value from sysUpTime and snmpEngineTime.
|
|
*
|
|
* Some agents, notably OpenBSD snmpd, return the current Unix timestamp for
|
|
* snmpEngineTime. That value is not an uptime and must not replace the real
|
|
* sysUpTime value. Legitimate engine time remains useful after the 32-bit
|
|
* TimeTicks value wraps, so retain the existing preference when it is at
|
|
* least the system uptime and does not resemble wall-clock time.
|
|
*
|
|
* @param mixed $system_uptime sysUpTime in hundredths of a second.
|
|
* @param mixed $engine_time snmpEngineTime in seconds.
|
|
* @param int|null $now Current Unix time, injectable for tests.
|
|
*
|
|
* @return int|false Selected uptime in hundredths of a second.
|
|
*/
|
|
function cacti_snmp_select_uptime(mixed $system_uptime, mixed $engine_time, ?int $now = null) : int|false {
|
|
$system_uptime = is_numeric($system_uptime) && $system_uptime >= 0 ? (int) $system_uptime : false;
|
|
|
|
if (!is_numeric($engine_time) || $engine_time <= 0) {
|
|
return $system_uptime;
|
|
}
|
|
|
|
$engine_time = (int) $engine_time;
|
|
$now = $now ?? time();
|
|
$epoch_range = 5 * 366 * 86400;
|
|
|
|
if ($now > $epoch_range && abs($engine_time - $now) <= $epoch_range) {
|
|
return $system_uptime;
|
|
}
|
|
|
|
$engine_uptime = $engine_time * 100;
|
|
|
|
return $system_uptime === false || $engine_uptime >= $system_uptime ? $engine_uptime : $system_uptime;
|
|
}
|
|
|
|
// trim all but hex-string:, which will return 'hex-'
|
|
// define('REGEXP_SNMP_TRIM', '/(counter(32|64):|gauge:|gauge(32|64):|float:|ipaddress:|string:|integer:)$/i');
|
|
define('REGEXP_SNMP_TRIM', '/(counter(32|64)|gauge|gauge(32|64)|float|ipaddress|string|integer):/i');
|
|
|
|
define('SNMP_METHOD_PHP', 1);
|
|
define('SNMP_METHOD_BINARY', 2);
|
|
|
|
if (!defined('SNMP_STRING_OUTPUT_GUESS')) {
|
|
define('SNMP_STRING_OUTPUT_GUESS', 1);
|
|
}
|
|
|
|
if (!defined('SNMP_STRING_OUTPUT_ASCII')) {
|
|
define('SNMP_STRING_OUTPUT_ASCII', 2);
|
|
}
|
|
|
|
if (!defined('SNMP_STRING_OUTPUT_HEX')) {
|
|
define('SNMP_STRING_OUTPUT_HEX', 3);
|
|
}
|
|
|
|
global $banned_snmp_strings;
|
|
$banned_snmp_strings = ['End of MIB', 'No Such', 'No more'];
|
|
|
|
if (CACTI_PHP_SNMP) {
|
|
include_once(CACTI_PATH_INCLUDE . '/vendor/phpsnmp/extension.php');
|
|
} else {
|
|
include_once(CACTI_PATH_INCLUDE . '/vendor/phpsnmp/classSNMP.php');
|
|
}
|
|
|
|
use phpsnmp\SNMP;
|
|
|
|
function cacti_snmp_session(string $hostname, mixed $community, mixed $version, mixed $auth_user = '', mixed $auth_pass = '',
|
|
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '', mixed $engineid = '',
|
|
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $max_oids = 10, mixed $bulk_walk_size = 10) : mixed {
|
|
switch ($version) {
|
|
case '1':
|
|
$version = SNMP::VERSION_1;
|
|
|
|
break;
|
|
case '2':
|
|
$version = SNMP::VERSION_2c;
|
|
|
|
break;
|
|
case '3':
|
|
$version = SNMP::VERSION_3;
|
|
|
|
break;
|
|
}
|
|
|
|
$timeout_us = (int) ($timeout_ms * 1000);
|
|
|
|
try {
|
|
$session = new SNMP($version, $hostname . ':' . (is_numeric($port) ? (int) $port : 161), ($version == 3 ? $auth_user : $community), $timeout_us, $retries);
|
|
} catch (Throwable $e) {
|
|
return false;
|
|
}
|
|
|
|
if (defined('SNMP_OID_OUTPUT_NUMERIC')) {
|
|
$session->oid_output_format = SNMP_OID_OUTPUT_NUMERIC;
|
|
$session->valueretrieval = SNMP_VALUE_PLAIN;
|
|
}
|
|
|
|
$session->quick_print = false;
|
|
$session->max_oids = $max_oids;
|
|
$session->bulk_walk_size = $bulk_walk_size;
|
|
|
|
if (read_config_option('oid_increasing_check_disable') == 'on') {
|
|
$session->oid_increasing_check = false;
|
|
}
|
|
|
|
if ($version != SNMP::VERSION_3) {
|
|
return $session;
|
|
}
|
|
|
|
if ($priv_proto == '[None]' || $priv_pass == '') {
|
|
if ($auth_pass == '' || $auth_proto == '[None]') {
|
|
$sec_level = 'noAuthNoPriv';
|
|
} else {
|
|
$sec_level = 'authNoPriv';
|
|
}
|
|
|
|
$priv_proto = '';
|
|
} else {
|
|
$sec_level = 'authPriv';
|
|
}
|
|
|
|
try {
|
|
$session->setSecurity($sec_level, $auth_proto, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
|
|
} catch (Throwable) {
|
|
return false;
|
|
}
|
|
|
|
return $session;
|
|
}
|
|
|
|
/**
|
|
* Gets a single SNMP value through the native extension or configured binary.
|
|
*
|
|
* @param callable|null $native_get Optional native getter override used by isolated callers and tests.
|
|
*
|
|
* @return string Formatted SNMP value, or `U` when the request fails.
|
|
*/
|
|
function cacti_snmp_get(string $hostname, mixed $community, string $oid, mixed $version, mixed $auth_user = '', mixed $auth_pass = '',
|
|
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '',
|
|
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $environ = 'SNMP',
|
|
mixed $engineid = '', int $value_output_format = SNMP_STRING_OUTPUT_GUESS, ?callable $native_get = null) : string {
|
|
global $snmp_error;
|
|
|
|
$max_oids = 1;
|
|
$snmp_error = '';
|
|
|
|
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $max_oids)) {
|
|
return 'U';
|
|
}
|
|
|
|
if (snmp_get_method('get', $version, $context, $engineid, $value_output_format) == SNMP_METHOD_PHP) {
|
|
// make sure snmp* is verbose so we can see what types of data we are getting back
|
|
snmp_set_quick_print(false);
|
|
|
|
if (function_exists('snmp_set_enum_print')) {
|
|
snmp_set_enum_print(true);
|
|
}
|
|
|
|
$timeout_us = (int) ($timeout_ms * 1000);
|
|
$snmp_value = 'U';
|
|
|
|
try {
|
|
if ($native_get !== null) {
|
|
$snmp_value = $native_get();
|
|
} elseif ($version == '1') {
|
|
$snmp_value = @snmpget($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
|
|
} elseif ($version == '2') {
|
|
$snmp_value = @snmp2_get($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
|
|
}
|
|
} catch (Throwable $ex) {
|
|
$snmp_error = $ex->getMessage();
|
|
}
|
|
|
|
if ($snmp_value === false) {
|
|
cacti_log("WARNING: SNMP Error:'$snmp_error', Device:'$hostname', OID:'$oid'", false, $environ);
|
|
$snmp_value = 'U';
|
|
} else {
|
|
$snmp_value = format_snmp_string($snmp_value, false, $value_output_format);
|
|
}
|
|
} else {
|
|
$snmp_value = '';
|
|
$hostname = cacti_format_ipv6_colon($hostname);
|
|
|
|
// net snmp want the timeout in seconds
|
|
$timeout_s = (int) ceil($timeout_ms / 1000);
|
|
|
|
if ($version == '1') {
|
|
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
|
|
} elseif ($version == '2') {
|
|
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
|
|
$version = '2c'; // ucd/net snmp prefers this over '2'
|
|
} elseif ($version == '3') {
|
|
$snmp_auth = cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
|
|
}
|
|
|
|
// no valid snmp version has been set, get out
|
|
if (empty($snmp_auth)) {
|
|
return 'U';
|
|
}
|
|
|
|
$command = cacti_escapeshellcmd(read_config_option('path_snmpget')) .
|
|
' -O fntevU' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
|
|
' -v ' . $version .
|
|
' -t ' . $timeout_s .
|
|
' -r ' . $retries .
|
|
' ' . cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port .
|
|
' ' . cacti_escapeshellarg($oid);
|
|
|
|
if (isset($_SESSION)) {
|
|
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
|
|
}
|
|
|
|
$return_var = 0;
|
|
exec($command, $snmp_value, $return_var);
|
|
|
|
$snmp_value = trim(implode(' ', $snmp_value));
|
|
|
|
// a non-zero exit signals snmpget failure even when the output omits 'Timeout'
|
|
if (str_contains($snmp_value, 'Timeout') || $return_var != 0) {
|
|
$reason = str_contains($snmp_value, 'Timeout') ? 'Timeout' : "Exit Code $return_var, Output:'$snmp_value'";
|
|
|
|
cacti_log("WARNING: SNMP Error:'$reason', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
|
|
$snmp_value = 'U';
|
|
} else {
|
|
$snmp_value = format_snmp_string($snmp_value, false, $value_output_format);
|
|
}
|
|
}
|
|
|
|
return $snmp_value;
|
|
}
|
|
|
|
function cacti_snmp_get_raw(string $hostname, mixed $community, string $oid, mixed $version, mixed $auth_user = '', string $auth_pass = '',
|
|
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '',
|
|
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $environ = SNMP_POLLER,
|
|
string $engineid = '', int $value_output_format = SNMP_STRING_OUTPUT_GUESS) : string {
|
|
global $snmp_error;
|
|
|
|
$max_oids = 1;
|
|
$snmp_error = '';
|
|
|
|
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $max_oids)) {
|
|
return 'U';
|
|
}
|
|
|
|
if (snmp_get_method('get', $version, $context, $engineid, $value_output_format) == SNMP_METHOD_PHP) {
|
|
$snmp_value = false;
|
|
|
|
/* make sure snmp* is verbose so we can see what types of data
|
|
we are getting back */
|
|
snmp_set_quick_print(false);
|
|
|
|
$timeout_us = (int) ($timeout_ms * 1000);
|
|
|
|
if (function_exists('snmp_set_enum_print')) {
|
|
snmp_set_enum_print(true);
|
|
}
|
|
|
|
if ($version == '1') {
|
|
$snmp_value = @snmpget($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
|
|
} elseif ($version == '2') {
|
|
$snmp_value = @snmp2_get($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
|
|
}
|
|
|
|
if ($snmp_value === false) {
|
|
cacti_log("WARNING: SNMP Error:'$snmp_error', Device:'$hostname', OID:'$oid'", false);
|
|
$snmp_value = 'U';
|
|
}
|
|
} else {
|
|
$snmp_value = '';
|
|
$hostname = cacti_format_ipv6_colon($hostname);
|
|
|
|
// net snmp want the timeout in seconds
|
|
$timeout_s = (int) ceil($timeout_ms / 1000);
|
|
|
|
if ($version == '1') {
|
|
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
|
|
} elseif ($version == '2') {
|
|
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
|
|
$version = '2c'; // ucd/net snmp prefers this over '2'
|
|
} elseif ($version == '3') {
|
|
$snmp_auth = cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
|
|
}
|
|
|
|
// no valid snmp version has been set, get out
|
|
if (empty($snmp_auth)) {
|
|
return 'U';
|
|
}
|
|
|
|
$command = cacti_escapeshellcmd(read_config_option('path_snmpget')) .
|
|
' -O fntev' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
|
|
' -v ' . $version .
|
|
' -t ' . $timeout_s .
|
|
' -r ' . $retries .
|
|
' ' . cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port .
|
|
' ' . cacti_escapeshellarg($oid);
|
|
|
|
if (isset($_SESSION)) {
|
|
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
|
|
}
|
|
|
|
$return_var = 0;
|
|
exec($command, $snmp_value, $return_var);
|
|
|
|
// fix for multi-line snmp output
|
|
$snmp_value = trim(implode(' ', $snmp_value));
|
|
|
|
// a non-zero exit signals snmpget failure even when the output omits 'Timeout'
|
|
if (str_contains($snmp_value, 'Timeout') || $return_var != 0) {
|
|
$reason = str_contains($snmp_value, 'Timeout') ? 'Timeout' : "Exit Code $return_var, Output:'$snmp_value'";
|
|
|
|
cacti_log("WARNING: SNMP Error:'$reason', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
|
|
$snmp_value = 'U';
|
|
}
|
|
}
|
|
|
|
return $snmp_value;
|
|
}
|
|
|
|
function cacti_snmp_getnext(string $hostname, mixed $community, mixed $oid, mixed $version, mixed $auth_user = '', mixed $auth_pass = '',
|
|
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '',
|
|
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $environ = 'SNMP',
|
|
string $engineid = '', int $value_output_format = SNMP_STRING_OUTPUT_GUESS) : string {
|
|
global $snmp_error;
|
|
|
|
$max_oids = 1;
|
|
$snmp_error = '';
|
|
|
|
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $max_oids)) {
|
|
return 'U';
|
|
}
|
|
|
|
if (snmp_get_method('getnext', $version, $context, $engineid, $value_output_format) == SNMP_METHOD_PHP) {
|
|
$snmp_value = false;
|
|
|
|
// make sure snmp* is verbose so we can see what types of data we are getting back
|
|
snmp_set_quick_print(false);
|
|
|
|
$timeout_us = (int) ($timeout_ms * 1000);
|
|
|
|
if ($version == '1') {
|
|
$snmp_value = snmpgetnext($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
|
|
} elseif ($version == '2') {
|
|
$snmp_value = snmp2_getnext($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
|
|
}
|
|
|
|
if ($snmp_value === false) {
|
|
cacti_log("WARNING: SNMP Error:'$snmp_error', Device:'$hostname', OID:'$oid'", false);
|
|
$snmp_value = 'U';
|
|
} else {
|
|
$snmp_value = format_snmp_string($snmp_value, false, $value_output_format);
|
|
}
|
|
} else {
|
|
$snmp_value = '';
|
|
$hostname = cacti_format_ipv6_colon($hostname);
|
|
|
|
// net snmp want the timeout in seconds
|
|
$timeout_s = (int) ceil($timeout_ms / 1000);
|
|
|
|
if ($version == '1') {
|
|
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
|
|
} elseif ($version == '2') {
|
|
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
|
|
$version = '2c'; // ucd/net snmp prefers this over '2'
|
|
} elseif ($version == '3') {
|
|
$snmp_auth = cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
|
|
}
|
|
|
|
// no valid snmp version has been set, get out
|
|
if (empty($snmp_auth)) {
|
|
return 'U';
|
|
}
|
|
|
|
$command = cacti_escapeshellcmd(read_config_option('path_snmpgetnext')) .
|
|
' -O fntevU' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
|
|
' -v ' . $version .
|
|
' -t ' . $timeout_s .
|
|
' -r ' . $retries .
|
|
' ' . cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port .
|
|
' ' . cacti_escapeshellarg($oid);
|
|
|
|
if (isset($_SESSION)) {
|
|
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
|
|
}
|
|
|
|
$return_var = 0;
|
|
exec($command, $snmp_value, $return_var);
|
|
|
|
$snmp_value = trim(implode(' ', $snmp_value));
|
|
|
|
// a non-zero exit signals snmpgetnext failure even when the output omits 'Timeout'
|
|
if (str_contains($snmp_value, 'Timeout') || $return_var != 0) {
|
|
$reason = str_contains($snmp_value, 'Timeout') ? 'Timeout' : "Exit Code $return_var, Output:'$snmp_value'";
|
|
|
|
cacti_log("WARNING: SNMP Error:'$reason', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
|
|
}
|
|
|
|
// strip out non-snmp data
|
|
$snmp_value = format_snmp_string($snmp_value, false, $value_output_format);
|
|
}
|
|
|
|
return $snmp_value;
|
|
}
|
|
|
|
function cacti_get_snmpv3_auth(mixed $auth_proto, mixed $auth_user, mixed $auth_pass, mixed $priv_proto, mixed $priv_pass, mixed $context, mixed $engineid) : string {
|
|
global $snmp_priv_protocols, $snmp_auth_protocols;
|
|
|
|
$sec_details = '';
|
|
|
|
if ($priv_proto == '[None]' || $priv_pass == '') {
|
|
if ($auth_pass == '' || $auth_proto == '[None]') {
|
|
$sec_level = 'noAuthNoPriv';
|
|
} else {
|
|
if (!array_key_exists($auth_proto, $snmp_auth_protocols)) {
|
|
return '';
|
|
}
|
|
|
|
$sec_level = 'authNoPriv';
|
|
$sec_details = ' -a ' . snmp_escape_string($snmp_auth_protocols[$auth_proto]) . ' -A ' . snmp_escape_string($auth_pass);
|
|
}
|
|
|
|
$priv_proto = '';
|
|
$priv_pass = '';
|
|
} else {
|
|
if (!array_key_exists($auth_proto, $snmp_auth_protocols) || !array_key_exists($priv_proto, $snmp_priv_protocols)) {
|
|
return '';
|
|
}
|
|
|
|
$sec_level = 'authPriv';
|
|
$sec_details = ' -a ' . snmp_escape_string($snmp_auth_protocols[$auth_proto]) . ' -A ' . snmp_escape_string($auth_pass);
|
|
$priv_proto = $snmp_priv_protocols[$priv_proto];
|
|
$priv_pass = '-X ' . snmp_escape_string($priv_pass) . ' -x ' . snmp_escape_string($priv_proto);
|
|
}
|
|
|
|
if ($context != '') {
|
|
$context = '-n ' . snmp_escape_string($context);
|
|
} else {
|
|
$context = '';
|
|
}
|
|
|
|
if ($engineid != '') {
|
|
$engineid = '-e ' . snmp_escape_string($engineid);
|
|
} else {
|
|
$engineid = '';
|
|
}
|
|
|
|
return trim('-u ' . snmp_escape_string($auth_user) .
|
|
' -l ' . snmp_escape_string($sec_level) .
|
|
' ' . $sec_details .
|
|
' ' . $priv_pass .
|
|
' ' . $context .
|
|
' ' . $engineid);
|
|
}
|
|
|
|
/**
|
|
* cacti_snmp_session_from_host - build an SNMP session from a device row.
|
|
*
|
|
* cacti_snmp_session() takes fifteen positional arguments. Callers across the
|
|
* poller, data queries, and automation each spelled out the same mapping from
|
|
* a $host row, which is where SNMPv3 credential handling drifted between them.
|
|
* This assembles the arguments once. Missing keys fall back to the same
|
|
* defaults cacti_snmp_session() already applies, so a partial row behaves as
|
|
* the explicit-argument calls did.
|
|
*
|
|
* @param array $host A device row with the snmp_* and ping_retries columns.
|
|
* @param mixed $bulk_walk_size Optional bulk walk size override.
|
|
*
|
|
* @return mixed The SNMP session object, or false on failure.
|
|
*/
|
|
function cacti_snmp_session_from_host(array $host, mixed $bulk_walk_size = 10) : mixed {
|
|
return cacti_snmp_session(
|
|
$host['hostname'] ?? '',
|
|
$host['snmp_community'] ?? '',
|
|
$host['snmp_version'] ?? '',
|
|
$host['snmp_username'] ?? '',
|
|
$host['snmp_password'] ?? '',
|
|
$host['snmp_auth_protocol'] ?? '',
|
|
$host['snmp_priv_passphrase'] ?? '',
|
|
$host['snmp_priv_protocol'] ?? '',
|
|
$host['snmp_context'] ?? '',
|
|
$host['snmp_engine_id'] ?? '',
|
|
$host['snmp_port'] ?? 161,
|
|
$host['snmp_timeout'] ?? 500,
|
|
$host['ping_retries'] ?? 0,
|
|
$host['max_oids'] ?? 10,
|
|
$bulk_walk_size
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Calls a native SNMP session method and captures its suppressed warning.
|
|
*
|
|
* Some PHP SNMP failures emit their only useful diagnostic as a warning while
|
|
* leaving the session error number and message empty.
|
|
*
|
|
* @param object $session Native SNMP session wrapper.
|
|
* @param string $method Native SNMP method name.
|
|
* @param array $args Method arguments.
|
|
* @param string $warning Captured warning message.
|
|
* @param callable|false|null $fallback_handler Previous handler override for isolated callers and tests.
|
|
*
|
|
* @return mixed Native SNMP method result.
|
|
*/
|
|
function cacti_snmp_session_call(object $session, string $method, array $args, string &$warning, callable|false|null $fallback_handler = null) : mixed {
|
|
$warning = '';
|
|
|
|
$previous_handler = set_error_handler(function (int $level, string $message, string $file = '', int $line = 0, array $context = []) use (&$warning, &$previous_handler) : bool {
|
|
if (($level & (E_WARNING | E_USER_WARNING)) !== 0) {
|
|
if ($warning === '') {
|
|
$warning = $message;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (is_callable($previous_handler)) {
|
|
return (bool) call_user_func($previous_handler, $level, $message, $file, $line, $context);
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
if ($fallback_handler !== null) {
|
|
$previous_handler = $fallback_handler;
|
|
}
|
|
|
|
try {
|
|
return @call_user_func_array([$session, $method], $args);
|
|
} finally {
|
|
restore_error_handler();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logs the error reported by a native SNMP session operation.
|
|
*
|
|
* @param object $session Native SNMP session wrapper.
|
|
* @param array $info Session connection metadata.
|
|
* @param string|array $oid OID or OID list used by the failed operation.
|
|
* @param string $warning Warning captured while calling the operation.
|
|
*
|
|
* @return void
|
|
*/
|
|
function cacti_snmp_log_session_error(object $session, array $info, string|array $oid, string $warning = '') : void {
|
|
$error_number = $session->getErrno();
|
|
|
|
if ($error_number == SNMP::ERRNO_TIMEOUT) {
|
|
$error = 'Timeout (' . round($info['timeout'] / 1000, 0) . ' ms)';
|
|
} else {
|
|
$error = trim((string) $session->getError());
|
|
|
|
if ($error === '') {
|
|
$error = trim($warning);
|
|
}
|
|
|
|
if ($error === '') {
|
|
$error = 'Error Number ' . $error_number;
|
|
}
|
|
}
|
|
|
|
$error = str_replace(["\r", "\n"], ' ', $error);
|
|
$oid = is_array($oid) ? implode(',', $oid) : $oid;
|
|
|
|
cacti_log("WARNING: SNMP Error:'$error', Device:'" . $info['hostname'] . "', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
|
|
}
|
|
|
|
function cacti_snmp_session_walk(object $session, mixed $oid, bool $dummy = false, mixed $max_repetitions = null,
|
|
mixed $non_repeaters = null, int $value_output_format = SNMP_STRING_OUTPUT_GUESS) : mixed {
|
|
$info = $session->info;
|
|
$out = [];
|
|
|
|
if (is_array($oid) && cacti_sizeof($oid) == 0) {
|
|
cacti_log('Empty OID!', false);
|
|
|
|
return $out;
|
|
}
|
|
|
|
if (is_array($oid)) {
|
|
foreach ($oid as $index => $o) {
|
|
$oid[$index] = trim($o);
|
|
}
|
|
} else {
|
|
$oid = trim($oid);
|
|
}
|
|
|
|
$session->value_output_format = $value_output_format;
|
|
|
|
if ($non_repeaters === null) {
|
|
$non_repeaters = 0;
|
|
}
|
|
|
|
if ($max_repetitions === null) {
|
|
$max_repetitions = $session->bulk_walk_size;
|
|
}
|
|
|
|
if ($max_repetitions <= 0) {
|
|
$max_repetitions = 10;
|
|
}
|
|
|
|
$warning = '';
|
|
|
|
try {
|
|
$out = cacti_snmp_session_call($session, 'walk', [$oid, false, $max_repetitions, $non_repeaters], $warning);
|
|
} catch (Exception $e) {
|
|
$out = false;
|
|
|
|
if ($warning === '') {
|
|
$warning = $e->getMessage();
|
|
}
|
|
}
|
|
|
|
if ($out === false) {
|
|
if ($oid == '.1.3.6.1.2.1.47.1.1.1.1.2' ||
|
|
$oid == '.1.3.6.1.4.1.9.9.68.1.2.2.1.2' ||
|
|
$oid == '.1.3.6.1.4.1.9.9.46.1.6.1.1.5' ||
|
|
$oid == '.1.3.6.1.4.1.9.9.46.1.6.1.1.14' ||
|
|
$oid == '.1.3.6.1.4.1.9.9.23.1.2.1.1.6') {
|
|
// do nothing
|
|
} else {
|
|
cacti_snmp_log_session_error($session, $info, $oid, $warning);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
if (cacti_sizeof($out)) {
|
|
foreach ($out as $oid => $value) {
|
|
if (is_array($value)) {
|
|
foreach ($value as $index => $sval) {
|
|
$out[$oid][$index] = format_snmp_string($sval, false, $value_output_format);
|
|
}
|
|
} elseif ($out[$oid] !== false) {
|
|
$out[$oid] = format_snmp_string($value, false, $value_output_format);
|
|
}
|
|
}
|
|
} else {
|
|
$out = format_snmp_string($oid, false, $value_output_format);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
function cacti_snmp_session_get(object $session, mixed $oid, bool $strip_alpha = false) : mixed {
|
|
$info = $session->info;
|
|
$out = [];
|
|
|
|
if (is_array($oid) && cacti_sizeof($oid) == 0) {
|
|
cacti_log('Empty OID!', false);
|
|
|
|
return $out;
|
|
}
|
|
|
|
if (is_array($oid)) {
|
|
foreach ($oid as $index => $o) {
|
|
$oid[$index] = trim($o);
|
|
}
|
|
} else {
|
|
$oid = trim($oid);
|
|
}
|
|
|
|
$warning = '';
|
|
|
|
try {
|
|
$out = cacti_snmp_session_call($session, 'get', [$oid], $warning);
|
|
} catch (Exception $e) {
|
|
$out = false;
|
|
|
|
if ($warning === '') {
|
|
$warning = $e->getMessage();
|
|
}
|
|
}
|
|
|
|
if (is_array($oid)) {
|
|
$oid = implode(',', $oid);
|
|
}
|
|
|
|
if ($out === false) {
|
|
cacti_snmp_log_session_error($session, $info, $oid, $warning);
|
|
|
|
return false;
|
|
}
|
|
|
|
if (is_array($out)) {
|
|
foreach ($out as $oid => $value) {
|
|
$out[$oid] = format_snmp_string($value, false, SNMP_STRING_OUTPUT_GUESS, $strip_alpha);
|
|
}
|
|
} else {
|
|
$out = format_snmp_string($out, false, SNMP_STRING_OUTPUT_GUESS, $strip_alpha);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
function cacti_snmp_session_getnext(object $session, mixed $oid) : mixed {
|
|
$info = $session->info;
|
|
$out = [];
|
|
|
|
if (is_array($oid) && cacti_sizeof($oid) == 0) {
|
|
cacti_log('Empty OID!', false);
|
|
|
|
return $out;
|
|
}
|
|
|
|
if (is_array($oid)) {
|
|
foreach ($oid as $index => $o) {
|
|
$oid[$index] = trim($o);
|
|
}
|
|
} else {
|
|
$oid = trim($oid);
|
|
}
|
|
|
|
$warning = '';
|
|
|
|
try {
|
|
$out = cacti_snmp_session_call($session, 'getnext', [$oid], $warning);
|
|
} catch (Exception $e) {
|
|
$out = false;
|
|
|
|
if ($warning === '') {
|
|
$warning = $e->getMessage();
|
|
}
|
|
}
|
|
|
|
if (is_array($oid)) {
|
|
$oid = implode(',', $oid);
|
|
}
|
|
|
|
if ($out === false) {
|
|
cacti_snmp_log_session_error($session, $info, $oid, $warning);
|
|
|
|
return false;
|
|
}
|
|
|
|
if (is_array($out)) {
|
|
foreach ($out as $oid => $value) {
|
|
$out[$oid] = format_snmp_string($value, false);
|
|
}
|
|
} else {
|
|
$out = format_snmp_string($out, false);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
function cacti_snmp_validate_oid(string $oid) : bool {
|
|
$oid = ltrim($oid, '.');
|
|
|
|
if ($oid === '') {
|
|
return false;
|
|
}
|
|
|
|
$validate = array_map('is_numeric', explode('.', $oid));
|
|
|
|
return !in_array(false, $validate, true);
|
|
}
|
|
|
|
function cacti_snmp_walk(string $hostname, mixed $community, string $oid, mixed $version, mixed $auth_user = '', mixed $auth_pass = '',
|
|
mixed $auth_proto = '', mixed $priv_pass = '', mixed $priv_proto = '', mixed $context = '',
|
|
mixed $port = 161, mixed $timeout_ms = 500, mixed $retries = 0, mixed $bulk_walk_size = 10, mixed $environ = 'SNMP',
|
|
mixed $engineid = '', int $value_output_format = SNMP_STRING_OUTPUT_GUESS) : array {
|
|
global $banned_snmp_strings, $snmp_error;
|
|
|
|
$snmp_error = '';
|
|
$snmp_oid_included = true;
|
|
$snmp_auth = '';
|
|
$snmp_array = [];
|
|
$temp_array = [];
|
|
|
|
if (!cacti_snmp_options_sanitize($version, $community, $port, $timeout_ms, $retries, $bulk_walk_size)) {
|
|
return $snmp_array;
|
|
}
|
|
|
|
$path_snmpbulkwalk = read_config_option('path_snmpbulkwalk');
|
|
|
|
if (snmp_get_method('walk', $version, $context, $engineid, $value_output_format) == SNMP_METHOD_PHP) {
|
|
/* make sure snmp* is verbose so we can see what types of data
|
|
we are getting back */
|
|
|
|
$timeout_us = (int) ($timeout_ms * 1000);
|
|
|
|
// force php to return numeric oid's
|
|
cacti_oid_numeric_format();
|
|
|
|
if (function_exists('snmprealwalk')) {
|
|
$snmp_oid_included = false;
|
|
}
|
|
|
|
snmp_set_quick_print(false);
|
|
|
|
if ($version == '1') {
|
|
$temp_array = snmprealwalk($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
|
|
} elseif ($version == 2) {
|
|
$temp_array = snmp2_real_walk($hostname . ':' . $port, $community, $oid, $timeout_us, $retries);
|
|
}
|
|
|
|
// check for bad entries
|
|
if ($temp_array !== false && cacti_sizeof($temp_array)) {
|
|
foreach ($temp_array as $key => $value) {
|
|
foreach ($banned_snmp_strings as $item) {
|
|
if (strstr($value, $item) != '') {
|
|
unset($temp_array[$key]);
|
|
|
|
continue 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
$o = 0;
|
|
|
|
for (reset($temp_array); $i = key($temp_array); next($temp_array)) {
|
|
if ($temp_array[$i] != 'NULL') {
|
|
$snmp_array[$o]['oid'] = preg_replace('/^\./', '', $i);
|
|
$snmp_array[$o]['value'] = format_snmp_string($temp_array[$i], $snmp_oid_included, $value_output_format);
|
|
}
|
|
$o++;
|
|
}
|
|
}
|
|
} else {
|
|
// ucd/net snmp want the timeout in seconds
|
|
$timeout_s = (int) ceil($timeout_ms / 1000);
|
|
$hostname = cacti_format_ipv6_colon($hostname);
|
|
|
|
if ($version == '1') {
|
|
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
|
|
} elseif ($version == '2') {
|
|
$snmp_auth = '-c ' . snmp_escape_string($community); // v1/v2 - community string
|
|
$version = '2c'; // ucd/net snmp prefers this over '2'
|
|
} elseif ($version == '3') {
|
|
$snmp_auth = cacti_get_snmpv3_auth($auth_proto, $auth_user, $auth_pass, $priv_proto, $priv_pass, $context, $engineid);
|
|
}
|
|
|
|
// cacti_get_snmpv3_auth() returns '' for an unknown protocol. Without this
|
|
// the walk builds a credential-less snmpwalk -v 3 and fails with no log
|
|
// line, unlike cacti_snmp_get/get_raw/getnext which all bail out here.
|
|
if (empty($snmp_auth)) {
|
|
cacti_log("WARNING: SNMP Error:'Missing credentials', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
|
|
|
|
return [];
|
|
}
|
|
|
|
if (read_config_option('oid_increasing_check_disable') == 'on') {
|
|
$oidCheck = '-Cc';
|
|
} else {
|
|
$oidCheck = '';
|
|
}
|
|
|
|
$return_code = 0;
|
|
|
|
if (file_exists($path_snmpbulkwalk) && ($version > 1) && ($bulk_walk_size > 1)) {
|
|
$command = cacti_escapeshellcmd($path_snmpbulkwalk) .
|
|
' -O QnU' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
|
|
' -v ' . $version .
|
|
' -t ' . $timeout_s .
|
|
' -r ' . $retries .
|
|
' -Cr' . $bulk_walk_size .
|
|
' ' . $oidCheck . ' ' .
|
|
cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port . ' ' .
|
|
cacti_escapeshellarg($oid);
|
|
|
|
if (isset($_SESSION)) {
|
|
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
|
|
}
|
|
|
|
$temp_array = exec_into_array($command, $return_code);
|
|
} else {
|
|
$command = cacti_escapeshellcmd(read_config_option('path_snmpwalk')) .
|
|
' -O QnU' . ($value_output_format == SNMP_STRING_OUTPUT_HEX ? 'x ' : ' ') . $snmp_auth .
|
|
' -v ' . $version .
|
|
' -t ' . $timeout_s .
|
|
' -r ' . $retries .
|
|
' ' . $oidCheck . ' ' .
|
|
' ' . cacti_escapeshellarg_cmd($hostname, true, true) . ':' . $port .
|
|
' ' . cacti_escapeshellarg($oid);
|
|
|
|
if (isset($_SESSION)) {
|
|
debug_log_insert('data_query', __esc('SNMP Command is: %s', $command));
|
|
}
|
|
|
|
$temp_array = exec_into_array($command, $return_code);
|
|
}
|
|
|
|
// net-snmp reports a timeout or an oversized response on stderr, which
|
|
// exec() does not capture, so the walk output can never contain either.
|
|
// Matching them against stdout only ever matched device data such as an
|
|
// ifAlias of 'Timeout Monitor', and discarded the whole walk. The exit
|
|
// code is the signal that actually distinguishes a failed walk.
|
|
if ($return_code != 0) {
|
|
cacti_log("WARNING: SNMP Error:'Exit Code $return_code', Device:'$hostname', OID:'$oid'", false, 'SNMP', POLLER_VERBOSITY_HIGH);
|
|
|
|
return [];
|
|
}
|
|
|
|
// check for bad entries
|
|
if (cacti_sizeof($temp_array)) {
|
|
foreach ($temp_array as $key => $value) {
|
|
foreach ($banned_snmp_strings as $item) {
|
|
if (strstr($value, $item) != '') {
|
|
unset($temp_array[$key]);
|
|
|
|
continue 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Using this technique to catch multi-line
|
|
* snmpwalk responses from net-snmp. This happens
|
|
* usually on sysDescr on Cisco devices.
|
|
*/
|
|
$i = 0;
|
|
|
|
foreach ($temp_array as $value) {
|
|
if (preg_match('/(.*) =.*/', $value)) {
|
|
$parts = explode('=', $value, 2);
|
|
$t_oid = trim($parts[0]);
|
|
$t_value = $parts[1];
|
|
|
|
if (!cacti_snmp_validate_oid($t_oid)) {
|
|
cacti_log(sprintf('WARNING: SNMP Agent exploit attempted on SNMP agent from host ip: %s with oid: %s', $hostname, $t_oid), false, 'SECURITY');
|
|
|
|
continue;
|
|
}
|
|
|
|
$snmp_array[$i]['oid'] = $t_oid;
|
|
$snmp_array[$i]['value'] = $t_value;
|
|
$i++;
|
|
} else {
|
|
$snmp_array[$i - 1]['value'] .= $value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* replay the array to escape value data in case of a multi-line exploit
|
|
*/
|
|
if (cacti_sizeof($snmp_array)) {
|
|
foreach ($snmp_array as $index => $data) {
|
|
$snmp_array[$index]['value'] = format_snmp_string($data['value'], false, $value_output_format);
|
|
}
|
|
}
|
|
|
|
return $snmp_array;
|
|
}
|
|
|
|
function format_snmp_string(string $string, bool $snmp_oid_included, int $value_output_format = SNMP_STRING_OUTPUT_GUESS, bool $strip_alpha = false) : string {
|
|
global $banned_snmp_strings;
|
|
|
|
$string = preg_replace(REGEXP_SNMP_TRIM, '', trim($string));
|
|
|
|
if ($snmp_oid_included) {
|
|
// strip off all leading junk (the oid and stuff)
|
|
$string_array = explode('=', $string, 2);
|
|
|
|
if (cacti_sizeof($string_array) == 1) {
|
|
// trim excess first
|
|
$string = trim($string);
|
|
} elseif ((str_starts_with($string, '.')) || (str_contains($string, '::'))) {
|
|
// drop the OID from the array
|
|
array_shift($string_array);
|
|
$string = trim(implode('=', $string_array));
|
|
} else {
|
|
$string = trim(implode('=', $string_array));
|
|
}
|
|
} else {
|
|
$string = trim($string);
|
|
}
|
|
|
|
// remove quotes and extraneous data
|
|
$string = trim($string, " \n\r\v\"'");
|
|
|
|
// return the easiest value
|
|
if ($string == '') {
|
|
return $string;
|
|
}
|
|
|
|
// now check for the second most obvious
|
|
if (is_numeric($string)) {
|
|
return $string;
|
|
}
|
|
|
|
// remove ALL quotes, and other special delimiters
|
|
$string = str_replace(['"', "'", '>', '<', '\\', "\n", "\r"], '', $string);
|
|
|
|
// account for invalid MIB files
|
|
if (str_contains($string, 'Wrong Type')) {
|
|
$string = strrev($string);
|
|
|
|
if ($position = strpos($string, ':')) {
|
|
$string = trim(strrev(substr($string, 0, $position)));
|
|
} else {
|
|
$string = trim(strrev($string));
|
|
}
|
|
}
|
|
|
|
// Remove invalid chars, if the string output is to be numeric
|
|
if ($strip_alpha && $value_output_format == SNMP_STRING_OUTPUT_GUESS) {
|
|
$string = trim(str_ireplace('hex:', '', $string));
|
|
$len = strlen($string);
|
|
$pos = $len - 1;
|
|
|
|
while ($pos > 0) {
|
|
$value = ord($string[$pos]);
|
|
|
|
if (($value < 48 || $value > 57) && $value != 32) {
|
|
$string[$pos] = ' ';
|
|
} else {
|
|
break;
|
|
}
|
|
|
|
$pos--;
|
|
}
|
|
|
|
$string = trim($string);
|
|
$len = strlen($string);
|
|
$pos = 0;
|
|
|
|
while ($pos < $len) {
|
|
$value = ord($string[$pos]);
|
|
|
|
if (($value < 48 || $value > 57) && $value != 32) {
|
|
$string[$pos] = ' ';
|
|
} else {
|
|
break;
|
|
}
|
|
|
|
$pos++;
|
|
}
|
|
|
|
$string = trim($string);
|
|
|
|
if ($string == '') {
|
|
return 'U';
|
|
}
|
|
}
|
|
|
|
// Remove non-printable characters, allow UTF-8
|
|
if ($value_output_format == SNMP_STRING_OUTPUT_GUESS) {
|
|
$string = preg_replace('/[^[:print:]\r\n]/', '', $string);
|
|
}
|
|
|
|
// Trim the string of trailing and leading spaces
|
|
$string = trim($string);
|
|
|
|
// convert hex strings to numeric values
|
|
if (is_hex_string($string)) {
|
|
/* the is_hex_string() function will remove the hex:
|
|
* and hex-string: from the passed value
|
|
*/
|
|
$output = '';
|
|
$parts = explode(' ', $string);
|
|
|
|
if (cacti_sizeof($parts) == 4) {
|
|
$ip_address = '';
|
|
|
|
// convert the hex string into an ascii string
|
|
foreach ($parts as $part) {
|
|
$decimal = hexdec($part);
|
|
|
|
$ip_address .= ($ip_address != '' ? '.' : '') . $decimal;
|
|
$output .= chr($decimal);
|
|
}
|
|
|
|
if (is_ipaddress($ip_address)) {
|
|
$string = $ip_address;
|
|
} else {
|
|
$string = $output;
|
|
}
|
|
// hex string is mac-address
|
|
} elseif (cacti_sizeof($parts) == 6) {
|
|
// convert the hex string into an ascii string
|
|
foreach ($parts as $part) {
|
|
$output .= ($output != '' ? ':' : '');
|
|
|
|
if ($part == '00') {
|
|
$output .= '00';
|
|
} else {
|
|
$output .= str_pad($part, 2, '0', STR_PAD_LEFT);
|
|
}
|
|
}
|
|
|
|
$string = $output;
|
|
}
|
|
} elseif (str_starts_with(cacti_strtolower($string), 'hex:')) {
|
|
// strip off the 'Hex:'
|
|
$string = trim(str_ireplace('hex:', '', $string));
|
|
|
|
// normalize some forms
|
|
$output = '';
|
|
$string = str_replace([' ', '-', '.'], ':', $string);
|
|
$parts = explode(':', $string);
|
|
|
|
if (!is_mac_address($string)) {
|
|
// convert the hex string into an ascii string
|
|
foreach ($parts as $part) {
|
|
$output .= ($output != '' ? ':' : '');
|
|
|
|
if ($part == '00') {
|
|
$output .= '00';
|
|
} else {
|
|
$output .= str_pad($part, 2, '0', STR_PAD_LEFT);
|
|
}
|
|
}
|
|
|
|
if (is_numeric($output)) {
|
|
$string = number_format((float) $output, 0, '', '');
|
|
} else {
|
|
$string = $output;
|
|
}
|
|
}
|
|
} elseif (preg_match('/Timeticks:\s\((\d+)\)\s/', $string, $matches)) {
|
|
$string = $matches[1];
|
|
}
|
|
|
|
foreach ($banned_snmp_strings as $item) {
|
|
if (str_contains($string, $item)) {
|
|
$string = '';
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $string;
|
|
}
|
|
|
|
/**
|
|
* Escapes an SNMP command argument for the active server operating system.
|
|
*
|
|
* @param string $string Argument to escape.
|
|
* @param string $server_os Server operating system identifier.
|
|
*
|
|
* @return string Escaped command argument.
|
|
*/
|
|
function snmp_escape_string(string $string, string $server_os = CACTI_SERVER_OS) : string {
|
|
if ($server_os == 'win32') {
|
|
/* GHSA-rjvj-r52f-8v5q: cmd.exe ignores the \" escape and toggles
|
|
* quoting on every ", so wrapping cannot neutralize & | ^ < > ( ).
|
|
* SNMP values never legitimately contain these, so strip them. */
|
|
$string = str_replace(['"', '&', '|', '^', '<', '>', '(', ')'], '', $string);
|
|
}
|
|
|
|
return cacti_escapeshellarg($string);
|
|
}
|
|
|
|
/**
|
|
* Selects the native PHP extension or command-line SNMP implementation.
|
|
*
|
|
* @param string $type SNMP operation type.
|
|
* @param mixed $version SNMP protocol version.
|
|
* @param mixed $context SNMPv3 context.
|
|
* @param mixed $engineid SNMPv3 engine identifier.
|
|
* @param int $value_output_format Requested output format.
|
|
* @param bool $php_snmp Whether the PHP SNMP extension is available.
|
|
*
|
|
* @return int One of the `SNMP_METHOD_*` constants.
|
|
*/
|
|
function snmp_get_method(string $type = 'walk', mixed $version = 1, mixed $context = '', mixed $engineid = '',
|
|
int $value_output_format = SNMP_STRING_OUTPUT_GUESS, bool $php_snmp = CACTI_PHP_SNMP) : int {
|
|
if (!$php_snmp) {
|
|
return SNMP_METHOD_BINARY;
|
|
}
|
|
|
|
if ($value_output_format == SNMP_STRING_OUTPUT_HEX) {
|
|
return SNMP_METHOD_BINARY;
|
|
}
|
|
|
|
if ($version == 3) {
|
|
return SNMP_METHOD_BINARY;
|
|
}
|
|
|
|
if ($type == 'walk' && file_exists(read_config_option('path_snmpbulkwalk'))) {
|
|
return SNMP_METHOD_BINARY;
|
|
}
|
|
|
|
if (function_exists('snmpget') && $version == 1) {
|
|
return SNMP_METHOD_PHP;
|
|
}
|
|
|
|
if (function_exists('snmp2_get') && $version == 2) {
|
|
return SNMP_METHOD_PHP;
|
|
} else {
|
|
return SNMP_METHOD_BINARY;
|
|
}
|
|
}
|
|
|
|
function cacti_snmp_options_sanitize(mixed $version, mixed $community, mixed &$port, mixed &$timeout, mixed &$retries, mixed &$max_oids) : bool {
|
|
// determine default retries
|
|
if ($retries == 0 || !is_numeric($retries)) {
|
|
$retries = intval(read_config_option('snmp_retries'));
|
|
|
|
if (empty($retries)) {
|
|
$retries = 3;
|
|
}
|
|
}
|
|
|
|
$version = intval($version);
|
|
|
|
// determine default max_oids
|
|
if ($max_oids == 0 || !is_numeric($max_oids)) {
|
|
$max_oids = intval(read_config_option('max_get_size'));
|
|
|
|
if (empty($max_oids)) {
|
|
$max_oids = 10;
|
|
}
|
|
}
|
|
|
|
// determine default timeout
|
|
if ($timeout == 0 || !is_numeric($timeout)) {
|
|
$timeout = intval(read_config_option('snmp_timeout'));
|
|
|
|
if (empty($timeout)) {
|
|
$timeout = 500;
|
|
}
|
|
}
|
|
|
|
// determine default port, and force it to an integer. Every net-snmp exec
|
|
// path interpolates $port raw into the command line (hostname:port); an int
|
|
// can never carry shell metacharacters, so this holds even if a caller ever
|
|
// passes a request-derived port rather than the mediumint column value.
|
|
if (empty($port) || !is_numeric($port)) {
|
|
$port = 161;
|
|
} else {
|
|
$port = (int) $port;
|
|
}
|
|
|
|
// do not attempt to poll invalid combinations
|
|
if ($version == 0 || ($community == '' && $version != 3)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|