diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 5e4f3db6..85181ac8 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -35,21 +35,12 @@ jobs: integration-test: runs-on: ${{ matrix.os }} - # A failure against the pinned release is a real failure. The develop entry - # is advisory: it is how a core regression becomes visible here, but it must - # not turn the plugin's own pull requests red. - continue-on-error: ${{ matrix.cacti != 'release/1.2.31' }} - strategy: fail-fast: false matrix: php: ['8.1', '8.2', '8.3', '8.4'] os: [ubuntu-latest] cacti: ['release/1.2.31'] - include: - - php: '8.4' - os: ubuntu-latest - cacti: 'develop' services: mariadb: @@ -95,10 +86,27 @@ jobs: echo "PHP_BINARY=$(command -v php)" >> "$GITHUB_ENV" - name: Run apt-get update - run: sudo apt-get update + run: | + for attempt in 1 2 3; do + if sudo timeout 3m apt-get \ + -o Dpkg::Lock::Timeout=60 \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + update; then + exit 0 + fi + + if [ "$attempt" -lt 3 ]; then + sleep 10 + fi + done + + echo 'apt-get update failed after three bounded attempts.' >&2 + exit 1 - name: Install System Dependencies - run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping + run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping libapache2-mod-php - name: Start SNMPD Agent and Test run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b7db7d7..ae48cd43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ --- develop --- +* security: Use prepared statements for the bulk form actions in notify_lists.php and notify_queue.php +* security: Bind $graph_id in get_allowed_thresholds() and get_allowed_threshold_logs() instead of interpolating it +* security: Route rfilter through db_qstr_rlike() where Cacti provides it, and quote it otherwise +* security: Quote the values substituted into trigger commands +* security: Escape the page, id and drp_action values printed into hidden inputs +* security: Remove the eval() calls from the RPN expression evaluator +* issue: Bulk actions on the Notification Lists page did nothing, because the action allowlist compared an int against strings +* issue: Bulk writes were discarded on MySQL, where Cacti's db_commit_transaction() never commits +* issue: An RPN expression dividing zero by zero pushed no result, corrupting the rest of the stack +* issue: An RPN expression taking the modulo of zero, the square root of a negative, or the log of zero aborted the poller or produced NAN +* issue: Deleting a notification list left soft-deleted devices pointing at it * issue#686: Applying a templated threshold to a graph via the wrench icon, creates a duplicate graph * issue#707: Excessive timeout for row caching prevents data from being updated timely * issue#710: Fixing Typo in thold_daemons.service File diff --git a/notify_lists.php b/notify_lists.php index 016e01dc..72c549e6 100644 --- a/notify_lists.php +++ b/notify_lists.php @@ -146,7 +146,19 @@ function form_actions() { global $actions, $assoc_actions; // ================= input validation ================= + /* + * get_filter_request_var() stores the value as an int, so the comparison + * has to be made on strings for the strict check to mean anything. + */ get_filter_request_var('drp_action'); + + $valid_actions = array_map('strval', array_keys($actions + $assoc_actions)); + + if (!in_array((string) get_request_var('drp_action'), $valid_actions, true)) { + raise_message(40); + header('Location: notify_lists.php?header=false'); + exit; + } // ==================================================== // if we are to save this form, instead of display it @@ -156,41 +168,54 @@ function form_actions() { if (isset_request_var('save_list')) { if ($selected_items != false) { if (get_request_var('drp_action') == '1') { // delete - db_execute('DELETE FROM plugin_notification_lists - WHERE ' . array_to_sql_or($selected_items, 'id')); - - db_execute('UPDATE host - SET thold_send_email = 0 - WHERE thold_send_email = 2 - AND deleted="" - AND ' . array_to_sql_or($selected_items, 'thold_host_email')); - - db_execute('UPDATE host - SET thold_send_email = 1 - WHERE thold_send_email = 3 - AND deleted="" - AND ' . array_to_sql_or($selected_items, 'thold_host_email')); - - db_execute('UPDATE host - SET thold_host_email = 0 - AND deleted="" - WHERE ' . array_to_sql_or($selected_items, 'thold_host_email')); - - db_execute('UPDATE thold_data - SET notify_warning = 0 - WHERE ' . array_to_sql_or($selected_items, 'notify_warning')); - - db_execute('UPDATE thold_data - SET notify_alert = 0 - WHERE ' . array_to_sql_or($selected_items, 'notify_alert')); - - db_execute('UPDATE thold_template - SET notify_warning = 0 - WHERE ' . array_to_sql_or($selected_items, 'notify_warning')); - - db_execute('UPDATE thold_template - SET notify_alert = 0 - WHERE ' . array_to_sql_or($selected_items, 'notify_alert')); + /* + * Bind positionally on the values: sanitize_unserialize_selected_items() + * preserves the submitted array's keys, and a string key would be read + * as a named parameter. + */ + $ids = array_map('intval', array_values($selected_items)); + $placeholders = implode(', ', array_fill(0, cacti_sizeof($ids), '?')); + + /* + * Issued as SQL rather than through db_begin_transaction() and + * friends: Cacti's db_commit_transaction() gates the commit on + * SELECT @@in_transaction, which only MariaDB defines. On MySQL that + * query fails, the commit is skipped, and every write here is + * discarded when the connection closes. + */ + db_execute('START TRANSACTION'); + + /* + * The host reset deliberately omits the deleted = "" predicate its + * siblings carry: a soft-deleted device that is later restored must + * not come back pointing at a list that no longer exists. + */ + $statements = [ + 'DELETE FROM plugin_notification_lists WHERE id IN (' . $placeholders . ')', + 'UPDATE host SET thold_send_email = 0 WHERE thold_send_email = 2 AND deleted = "" AND thold_host_email IN (' . $placeholders . ')', + 'UPDATE host SET thold_send_email = 1 WHERE thold_send_email = 3 AND deleted = "" AND thold_host_email IN (' . $placeholders . ')', + 'UPDATE host SET thold_host_email = 0 WHERE thold_host_email IN (' . $placeholders . ')', + 'UPDATE thold_data SET notify_warning = 0 WHERE notify_warning IN (' . $placeholders . ')', + 'UPDATE thold_data SET notify_alert = 0 WHERE notify_alert IN (' . $placeholders . ')', + 'UPDATE thold_template SET notify_warning = 0 WHERE notify_warning IN (' . $placeholders . ')', + 'UPDATE thold_template SET notify_alert = 0 WHERE notify_alert IN (' . $placeholders . ')' + ]; + + $ok = true; + + foreach ($statements as $sql) { + if (!db_execute_prepared($sql, $ids)) { + $ok = false; + + break; + } + } + + if ($ok) { + db_execute('COMMIT'); + } else { + db_execute('ROLLBACK'); + } } elseif (get_request_var('drp_action') == '2') { // duplicate $i = 1; @@ -237,48 +262,60 @@ function form_actions() { if (isset_request_var('save_associate')) { if ($selected_items != false) { + get_filter_request_var('id'); get_filter_request_var('notification_action'); + get_filter_request_var('notification_warning_action'); + get_filter_request_var('notification_alert_action'); + + db_execute('START TRANSACTION'); + + $ok = true; if (get_request_var('drp_action') == '1') { // associate - for ($i = 0; ($i < count($selected_items)); $i++) { + for ($i = 0; ($i < cacti_sizeof($selected_items)); $i++) { // set the notification list - db_execute('UPDATE host - SET thold_host_email=' . get_request_var('id') . ' - WHERE id=' . $selected_items[$i] . ' - AND deleted=""'); + $ok = db_execute_prepared('UPDATE host + SET thold_host_email = ? + WHERE id = ? + AND deleted = ""', + [get_request_var('id'), $selected_items[$i]]) && $ok; // set the global/list election - db_execute('UPDATE host - SET thold_send_email=' . get_request_var('notification_action') . ' - WHERE id=' . $selected_items[$i] . ' - AND deleted=""'); + $ok = db_execute_prepared('UPDATE host + SET thold_send_email = ? + WHERE id = ? + AND deleted = ""', + [get_request_var('notification_action'), $selected_items[$i]]) && $ok; if (get_request_var('notification_warning_action') > 0) { // clear other settings if (get_request_var('notification_warning_action') == 1) { // set the notification list - db_execute('UPDATE thold_data AS td + $ok = db_execute_prepared('UPDATE thold_data AS td LEFT JOIN thold_template AS tt ON td.thold_template_id = tt.id - SET td.notify_warning=' . get_request_var('id') . ' - WHERE td.host_id=' . $selected_items[$i] . ' - AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)'); + SET td.notify_warning = ? + WHERE td.host_id = ? + AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)', + [get_request_var('id'), $selected_items[$i]]) && $ok; // clear other items - db_execute("UPDATE thold_data AS td + $ok = db_execute_prepared('UPDATE thold_data AS td LEFT JOIN thold_template AS tt ON td.thold_template_id = tt.id - SET td.notify_warning_extra='' - WHERE td.host_id=" . $selected_items[$i] . ' - AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)'); + SET td.notify_warning_extra = \'\' + WHERE td.host_id = ? + AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)', + [$selected_items[$i]]) && $ok; } else { // set the notification list - db_execute('UPDATE thold_data AS td + $ok = db_execute_prepared('UPDATE thold_data AS td LEFT JOIN thold_template AS tt ON td.thold_template_id = tt.id - SET td.notify_warning=' . get_request_var('id') . ' - WHERE td.host_id=' . $selected_items[$i] . ' - AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)'); + SET td.notify_warning = ? + WHERE td.host_id = ? + AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)', + [get_request_var('id'), $selected_items[$i]]) && $ok; } } @@ -286,78 +323,100 @@ function form_actions() { // clear other settings if (get_request_var('notification_alert_action') == 1) { // set the notification list - db_execute('UPDATE thold_data AS td + $ok = db_execute_prepared('UPDATE thold_data AS td LEFT JOIN thold_template AS tt ON td.thold_template_id = tt.id - SET td.notify_alert=' . get_request_var('id') . ' - WHERE td.host_id=' . $selected_items[$i] . ' - AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)'); + SET td.notify_alert = ? + WHERE td.host_id = ? + AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)', + [get_request_var('id'), $selected_items[$i]]) && $ok; // clear other items - db_execute("UPDATE thold_data AS td + $ok = db_execute_prepared('UPDATE thold_data AS td LEFT JOIN thold_template AS tt ON td.thold_template_id = tt.id - SET td.notify_extra='' - WHERE host_id=" . $selected_items[$i] . ' - AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)'); + SET td.notify_extra = \'\' + WHERE host_id = ? + AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)', + [$selected_items[$i]]) && $ok; // remove legacy contacts - db_execute('DELETE pttc + $ok = db_execute_prepared('DELETE pttc FROM plugin_thold_threshold_contact AS pttc INNER JOIN thold_data AS td ON pttc.thold_id = td.id LEFT JOIN thold_template AS tt ON td.thold_template_id = tt.id - WHERE td.host_id=' . $selected_items[$i] . ' - AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)'); + WHERE td.host_id = ? + AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)', + [$selected_items[$i]]) && $ok; } else { // set the notification list - db_execute('UPDATE thold_data AS td + $ok = db_execute_prepared('UPDATE thold_data AS td LEFT JOIN thold_template AS tt ON td.thold_template_id = tt.id - SET td.notify_alert=' . get_request_var('id') . ' - WHERE td.host_id=' . $selected_items[$i] . ' - AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)'); + SET td.notify_alert = ? + WHERE td.host_id = ? + AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)', + [get_request_var('id'), $selected_items[$i]]) && $ok; } } + + if (!$ok) { + break; + } } } elseif (get_request_var('drp_action') == '2') { // disassociate - for ($i = 0; ($i < count($selected_items)); $i++) { + for ($i = 0; ($i < cacti_sizeof($selected_items)); $i++) { // set the notification list - db_execute('UPDATE host - SET thold_host_email=0 - WHERE id=' . $selected_items[$i] . ' - AND deleted=""'); + $ok = db_execute_prepared('UPDATE host + SET thold_host_email = 0 + WHERE id = ? + AND deleted = ""', + [$selected_items[$i]]) && $ok; // set the global/list election - db_execute('UPDATE host - SET thold_send_email=' . get_request_var('notification_action') . ' - WHERE id=' . $selected_items[$i] . ' - AND deleted=""'); + $ok = db_execute_prepared('UPDATE host + SET thold_send_email = ? + WHERE id = ? + AND deleted = ""', + [get_request_var('notification_action'), $selected_items[$i]]) && $ok; if (get_request_var('notification_warning_action') > 0) { // set the notification list - db_execute('UPDATE thold_data AS td + $ok = db_execute_prepared('UPDATE thold_data AS td LEFT JOIN thold_template AS tt ON td.thold_template_id = tt.id SET td.notify_warning = 0 - WHERE td.host_id=' . $selected_items[$i] . ' + WHERE td.host_id = ? AND (tt.notify_templated = "" OR tt.notify_templated IS NULL) - AND td.notify_warning=' . get_request_var('id')); + AND td.notify_warning = ?', + [$selected_items[$i], get_request_var('id')]) && $ok; } if (get_request_var('notification_alert_action') > 0) { // set the notification list - db_execute('UPDATE thold_data AS td + $ok = db_execute_prepared('UPDATE thold_data AS td LEFT JOIN thold_template AS tt ON td.thold_template_id = tt.id - SET td.notify_alert=0 - WHERE td.host_id=' . $selected_items[$i] . ' + SET td.notify_alert = 0 + WHERE td.host_id = ? AND (tt.notify_templated = "" OR tt.notify_templated IS NULL) - AND td.notify_alert=' . get_request_var('id')); + AND td.notify_alert = ?', + [$selected_items[$i], get_request_var('id')]) && $ok; + } + + if (!$ok) { + break; } } } + + if ($ok) { + db_execute('COMMIT'); + } else { + db_execute('ROLLBACK'); + } } header('Location: notify_lists.php?header=false&action=edit&tab=hosts&id=' . get_request_var('id')); @@ -366,27 +425,38 @@ function form_actions() { if (isset_request_var('save_templates')) { if ($selected_items != false) { + get_filter_request_var('id'); get_filter_request_var('notification_action'); + get_filter_request_var('notification_warning_action'); + get_filter_request_var('notification_alert_action'); + + db_execute('START TRANSACTION'); + + $ok = true; + $update_template = []; if (get_request_var('drp_action') == '1') { // associate - for ($i = 0; ($i < count($selected_items)); $i++) { + for ($i = 0; ($i < cacti_sizeof($selected_items)); $i++) { if (get_request_var('notification_warning_action') > 0) { // clear other settings if (get_request_var('notification_warning_action') == 1) { // set the notification list - db_execute('UPDATE thold_template - SET notify_warning=' . get_request_var('id') . ' - WHERE id=' . $selected_items[$i]); + $ok = db_execute_prepared('UPDATE thold_template + SET notify_warning = ? + WHERE id = ?', + [get_request_var('id'), $selected_items[$i]]) && $ok; // clear other items - db_execute("UPDATE thold_template - SET notify_warning_extra='' - WHERE id=" . $selected_items[$i]); + $ok = db_execute_prepared("UPDATE thold_template + SET notify_warning_extra = '' + WHERE id = ?", + [$selected_items[$i]]) && $ok; } else { // set the notification list - db_execute('UPDATE thold_template - SET notify_warning=' . get_request_var('id') . ' - WHERE id=' . $selected_items[$i]); + $ok = db_execute_prepared('UPDATE thold_template + SET notify_warning = ? + WHERE id = ?', + [get_request_var('id'), $selected_items[$i]]) && $ok; } } @@ -394,47 +464,74 @@ function form_actions() { // clear other settings if (get_request_var('notification_alert_action') == 1) { // set the notification list - db_execute('UPDATE thold_template - SET notify_alert=' . get_request_var('id') . ' - WHERE id=' . $selected_items[$i]); + $ok = db_execute_prepared('UPDATE thold_template + SET notify_alert = ? + WHERE id = ?', + [get_request_var('id'), $selected_items[$i]]) && $ok; // clear other items - db_execute("UPDATE thold_template - SET notify_extra='' - WHERE id=" . $selected_items[$i]); - - db_execute('DELETE FROM plugin_thold_template_contact - WHERE template_id=' . $selected_items[$i]); + $ok = db_execute_prepared("UPDATE thold_template + SET notify_extra = '' + WHERE id = ?", + [$selected_items[$i]]) && $ok; + + $ok = db_execute_prepared('DELETE FROM plugin_thold_template_contact + WHERE template_id = ?', + [$selected_items[$i]]) && $ok; } else { // set the notification list - db_execute('UPDATE thold_template - SET notify_alert=' . get_request_var('id') . ' - WHERE id=' . $selected_items[$i]); + $ok = db_execute_prepared('UPDATE thold_template + SET notify_alert = ? + WHERE id = ?', + [get_request_var('id'), $selected_items[$i]]) && $ok; } } - thold_template_update_thresholds($selected_items[$i]); + $update_template[] = $selected_items[$i]; + + if (!$ok) { + break; + } } } elseif (get_request_var('drp_action') == '2') { // disassociate - for ($i = 0; ($i < count($selected_items)); $i++) { + for ($i = 0; ($i < cacti_sizeof($selected_items)); $i++) { if (get_request_var('notification_warning_action') > 0) { // set the notification list - db_execute('UPDATE thold_template - SET notify_warning=0 - WHERE id=' . $selected_items[$i] . ' - AND notify_warning=' . get_request_var('id')); + $ok = db_execute_prepared('UPDATE thold_template + SET notify_warning = 0 + WHERE id = ? + AND notify_warning = ?', + [$selected_items[$i], get_request_var('id')]) && $ok; } if (get_request_var('notification_alert_action') > 0) { // set the notification list - db_execute('UPDATE thold_template - SET notify_alert=0 - WHERE id=' . $selected_items[$i] . ' - AND notify_alert=' . get_request_var('id')); + $ok = db_execute_prepared('UPDATE thold_template + SET notify_alert = 0 + WHERE id = ? + AND notify_alert = ?', + [$selected_items[$i], get_request_var('id')]) && $ok; + } + + $update_template[] = $selected_items[$i]; + + if (!$ok) { + break; } + } + } + + if ($ok) { + db_execute('COMMIT'); - thold_template_update_thresholds($selected_items[$i]); + // Propagate template changes to threshold instances after the + // notification assignment is committed so this cascade does not + // participate in the transaction boundary. + foreach ($update_template as $template_id) { + thold_template_update_thresholds($template_id); } + } else { + db_execute('ROLLBACK'); } } @@ -444,27 +541,37 @@ function form_actions() { if (isset_request_var('save_tholds')) { if ($selected_items != false) { + get_filter_request_var('id'); get_filter_request_var('notification_action'); + get_filter_request_var('notification_warning_action'); + get_filter_request_var('notification_alert_action'); + + db_execute('START TRANSACTION'); + + $ok = true; if (get_request_var('drp_action') == '1') { // associate - for ($i = 0; ($i < count($selected_items)); $i++) { + for ($i = 0; ($i < cacti_sizeof($selected_items)); $i++) { if (get_request_var('notification_warning_action') > 0) { // clear other settings if (get_request_var('notification_warning_action') == 1) { // set the notification list - db_execute('UPDATE thold_data - SET notify_warning=' . get_request_var('id') . ' - WHERE id=' . $selected_items[$i]); + $ok = db_execute_prepared('UPDATE thold_data + SET notify_warning = ? + WHERE id = ?', + [get_request_var('id'), $selected_items[$i]]) && $ok; // clear other items - db_execute("UPDATE thold_data - SET notify_warning_extra='' - WHERE id=" . $selected_items[$i]); + $ok = db_execute_prepared("UPDATE thold_data + SET notify_warning_extra = '' + WHERE id = ?", + [$selected_items[$i]]) && $ok; } else { // set the notification list - db_execute('UPDATE thold_data - SET notify_warning=' . get_request_var('id') . ' - WHERE id=' . $selected_items[$i]); + $ok = db_execute_prepared('UPDATE thold_data + SET notify_warning = ? + WHERE id = ?', + [get_request_var('id'), $selected_items[$i]]) && $ok; } } @@ -472,43 +579,64 @@ function form_actions() { // clear other settings if (get_request_var('notification_alert_action') == 1) { // set the notification list - db_execute('UPDATE thold_data - SET notify_alert=' . get_request_var('id') . ' - WHERE id=' . $selected_items[$i]); + $ok = db_execute_prepared('UPDATE thold_data + SET notify_alert = ? + WHERE id = ?', + [get_request_var('id'), $selected_items[$i]]) && $ok; // clear other items - db_execute("UPDATE thold_data - SET notify_extra='' - WHERE id=" . $selected_items[$i]); - - db_execute('DELETE FROM plugin_thold_threshold_contact WHERE thold_id=' . $selected_items[$i]); + $ok = db_execute_prepared("UPDATE thold_data + SET notify_extra = '' + WHERE id = ?", + [$selected_items[$i]]) && $ok; + + $ok = db_execute_prepared('DELETE FROM plugin_thold_threshold_contact + WHERE thold_id = ?', + [$selected_items[$i]]) && $ok; } else { // set the notification list - db_execute('UPDATE thold_data - SET notify_alert=' . get_request_var('id') . ' - WHERE id=' . $selected_items[$i]); + $ok = db_execute_prepared('UPDATE thold_data + SET notify_alert = ? + WHERE id = ?', + [get_request_var('id'), $selected_items[$i]]) && $ok; } } + + if (!$ok) { + break; + } } } elseif (get_request_var('drp_action') == '2') { // disassociate - for ($i = 0; ($i < count($selected_items)); $i++) { + for ($i = 0; ($i < cacti_sizeof($selected_items)); $i++) { if (get_request_var('notification_warning_action') > 0) { // set the notification list - db_execute('UPDATE thold_data - SET notify_warning=0 - WHERE id=' . $selected_items[$i] . ' - AND notify_warning=' . get_request_var('id')); + $ok = db_execute_prepared('UPDATE thold_data + SET notify_warning = 0 + WHERE id = ? + AND notify_warning = ?', + [$selected_items[$i], get_request_var('id')]) && $ok; } if (get_request_var('notification_alert_action') > 0) { // set the notification list - db_execute('UPDATE thold_data - SET notify_alert=0 - WHERE id=' . $selected_items[$i] . ' - AND notify_alert=' . get_request_var('id')); + $ok = db_execute_prepared('UPDATE thold_data + SET notify_alert = 0 + WHERE id = ? + AND notify_alert = ?', + [$selected_items[$i], get_request_var('id')]) && $ok; + } + + if (!$ok) { + break; } } } + + if ($ok) { + db_execute('COMMIT'); + } else { + db_execute('ROLLBACK'); + } } header('Location: notify_lists.php?header=false&action=edit&tab=tholds&id=' . get_request_var('id')); @@ -590,7 +718,7 @@ function form_actions() { - + $save_html "; @@ -665,10 +793,10 @@ function form_actions() { print " - + - + $save_html "; @@ -743,10 +871,10 @@ function form_actions() { print " - + - + $save_html "; @@ -828,10 +956,10 @@ function form_actions() { print " - + - + $save_html "; @@ -1139,11 +1267,11 @@ function hosts($header_label) { function applyFilter() { strURL = '?header=false&action=edit&id=' - strURL += '&rows=' + $('#rows').val(); - strURL += '&host_template_id=' + $('#host_template_id').val(); - strURL += '&site_id=' + $('#site_id').val(); + strURL += '&rows=' + encodeURIComponent($('#rows').val()); + strURL += '&host_template_id=' + encodeURIComponent($('#host_template_id').val()); + strURL += '&site_id=' + encodeURIComponent($('#site_id').val()); strURL += '&associated=' + $('#associated').is(':checked'); - strURL += '&rfilter=' + base64_encode($('#rfilter').val()); + strURL += '&rfilter=' + encodeURIComponent(base64_encode($('#rfilter').val())); loadPageNoHeader(strURL); } @@ -1399,7 +1527,9 @@ function tholds($header_label) { } if (strlen(get_request_var('rfilter'))) { - $sql_where .= (!strlen($sql_where) ? '' : ' AND ') . "td.name_cache RLIKE '" . get_request_var('rfilter') . "'"; + // rfilter is pre-validated as a legal PHP regex by FILTER_VALIDATE_IS_REGEX in the + // request validation array; db_qstr() SQL-escapes the already-validated value. + $sql_where .= (!strlen($sql_where) ? '' : ' AND ') . 'td.name_cache ' . thold_rlike_clause(get_request_var('rfilter')); } if ($statefilter != '') { @@ -1509,11 +1639,11 @@ function tholds($header_label) { function applyFilter() { strURL = 'notify_lists.php?header=false&action=edit&tab=tholds&id=' strURL += '&associated=' + $('#associated').is(':checked'); - strURL += '&state=' + $('#state').val(); - strURL += '&site_id=' + $('#site_id').val(); - strURL += '&rows=' + $('#rows').val(); - strURL += '&template=' + $('#template').val(); - strURL += '&rfilter=' + base64_encode($('#rfilter').val()); + strURL += '&state=' + encodeURIComponent($('#state').val()); + strURL += '&site_id=' + encodeURIComponent($('#site_id').val()); + strURL += '&rows=' + encodeURIComponent($('#rows').val()); + strURL += '&template=' + encodeURIComponent($('#template').val()); + strURL += '&rfilter=' + encodeURIComponent(base64_encode($('#rfilter').val())); loadPageNoHeader(strURL); } @@ -1739,7 +1869,9 @@ function templates($header_label) { } if (strlen(get_request_var('rfilter'))) { - $sql_where .= (!strlen($sql_where) ? 'WHERE ' : ' AND ') . "thold_template.name RLIKE '" . get_request_var('rfilter') . "'"; + // rfilter is pre-validated as a legal PHP regex by FILTER_VALIDATE_IS_REGEX in the + // request validation array; db_qstr() SQL-escapes the already-validated value. + $sql_where .= (!strlen($sql_where) ? 'WHERE ' : ' AND ') . 'thold_template.name ' . thold_rlike_clause(get_request_var('rfilter')); } $sql = "SELECT * @@ -1798,8 +1930,8 @@ function templates($header_label) { function applyFilter() { strURL = 'notify_lists.php?header=false&action=edit&tab=templates&id=' strURL += '&associated=' + $('#associated').is(':checked'); - strURL += '&rows=' + $('#rows').val(); - strURL += '&rfilter=' + base64_encode($('#rfilter').val()); + strURL += '&rows=' + encodeURIComponent($('#rows').val()); + strURL += '&rfilter=' + encodeURIComponent(base64_encode($('#rfilter').val())); loadPageNoHeader(strURL); } @@ -2117,8 +2249,8 @@ function lists() { function applyFilter() { strURL = 'notify_lists.php?header=false'; - strURL += '&rows=' + $('#rows').val(); - strURL += '&rfilter=' + base64_encode($('#rfilter').val()); + strURL += '&rows=' + encodeURIComponent($('#rows').val()); + strURL += '&rfilter=' + encodeURIComponent(base64_encode($('#rfilter').val())); loadPageNoHeader(strURL); } @@ -2143,10 +2275,12 @@ function clearFilter() { // form the 'where' clause for our main sql query if (strlen(get_request_var('rfilter'))) { - $sql_where = "WHERE ( - name RLIKE '" . get_request_var('rfilter') . "' - OR description RLIKE '" . get_request_var('rfilter') . "' - OR emails RLIKE '" . get_request_var('rfilter') . "')"; + // rfilter is pre-validated as a legal PHP regex by FILTER_VALIDATE_IS_REGEX in the + // request validation array; db_qstr() SQL-escapes the already-validated value. + $sql_where = 'WHERE ( + name ' . thold_rlike_clause(get_request_var('rfilter')) . ' + OR description ' . thold_rlike_clause(get_request_var('rfilter')) . ' + OR emails ' . thold_rlike_clause(get_request_var('rfilter')) . ')'; } else { $sql_where = ''; } diff --git a/notify_queue.php b/notify_queue.php index 12f24645..51a6a4e3 100644 --- a/notify_queue.php +++ b/notify_queue.php @@ -110,7 +110,13 @@ function form_actions() { if ($selected_items != false) { if (get_nfilter_request_var('drp_action') == '1') { // delete - db_execute('DELETE FROM notification_queue WHERE ' . array_to_sql_or($selected_items, 'id')); + $placeholders = implode(', ', array_fill(0, cacti_sizeof($selected_items), '?')); + $params = array_map('intval', array_values($selected_items)); + + db_execute_prepared( + "DELETE FROM notification_queue WHERE id IN ($placeholders)", + $params + ); } } @@ -318,10 +324,10 @@ function notify_queue() { function applyFilter() { strURL = 'notify_queue.php?header=false'; - strURL += '&filter='+$('#filter').val(); - strURL += '&rows='+$('#rows').val(); - strURL += '&processed='+$('#processed').val(); - strURL += '&topic='+$('#topic').val(); + strURL += '&filter='+encodeURIComponent($('#filter').val()); + strURL += '&rows='+encodeURIComponent($('#rows').val()); + strURL += '&processed='+encodeURIComponent($('#processed').val()); + strURL += '&topic='+encodeURIComponent($('#topic').val()); loadPageNoHeader(strURL); } diff --git a/setup.php b/setup.php index b9aedfca..45298018 100644 --- a/setup.php +++ b/setup.php @@ -691,7 +691,7 @@ function thold_device_action_execute($action) { $selected_items = sanitize_unserialize_selected_items(get_nfilter_request_var('selected_items')); if ($selected_items != false) { - for ($i = 0; ($i < count($selected_items)); $i++) { + for ($i = 0; ($i < cacti_sizeof($selected_items)); $i++) { autocreate($selected_items[$i]); } } diff --git a/tests/Unit/GetAllowedThresholdsTest.php b/tests/Unit/GetAllowedThresholdsTest.php new file mode 100644 index 00000000..f62796ab --- /dev/null +++ b/tests/Unit/GetAllowedThresholdsTest.php @@ -0,0 +1,230 @@ + + */ + public static function accessorProvider() { + return [ + 'thresholds' => ['get_allowed_thresholds'], + 'logs' => ['get_allowed_threshold_logs'], + ]; + } + + /** + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testGraphIdIsBoundRatherThanInterpolated($function): void { + $total = 0; + $function('', 'td.name', '', $total, -1, 42); + + $call = CactiStubs::callsTo('db_fetch_assoc_prepared')[0]; + + $this->assertStringContainsString('gl.id = ?', $call['sql']); + $this->assertStringNotContainsString('42', $call['sql']); + $this->assertSame([42], $call['params']); + } + + /** + * A caller injecting SQL through $graph_id must end up with the payload as + * an inert bound value, never as query text. + * + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testMaliciousGraphIdNeverReachesQueryText($function): void { + $payload = '1 UNION SELECT password FROM user_auth'; + $total = 0; + $function('', 'td.name', '', $total, -1, $payload); + + foreach (CactiStubs::$calls as $call) { + $this->assertStringNotContainsString('UNION', $call['sql']); + } + } + + /** + * The $graph_id placeholder is appended after the caller's fragment, so the + * caller's own values have to come first in $sql_params for the binding to + * line up. + * + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testCallerParametersAreBoundBeforeTheGraphIdParameter($function): void { + $total = 0; + $function('td.thold_type = ?', 'td.name', '', $total, -1, 7, [3]); + + $call = CactiStubs::callsTo('db_fetch_assoc_prepared')[0]; + + $this->assertSame([3, 7], $call['params']); + $this->assertStringContainsString('td.thold_type = ? AND gl.id = ?', $call['sql']); + } + + /** + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testNoWhereClauseIsEmittedWhenNothingFiltersTheQuery($function): void { + $total = 0; + $function('', 'td.name', '', $total, -1, 0); + + $call = CactiStubs::callsTo('db_fetch_assoc_prepared')[0]; + + $this->assertStringNotContainsString('WHERE', $call['sql']); + $this->assertSame([], $call['params']); + } + + /** + * The row-count query reuses the same WHERE clause, so it has to receive + * the same bound values. + * + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testRowCountQueryBindsTheSameParameters($function): void { + $total = 0; + $function('td.thold_type = ?', 'td.name', '', $total, -1, 7, [3]); + + $count = CactiStubs::callsTo('db_fetch_cell_prepared')[0]; + + $this->assertSame([3, 7], $count['params']); + } + + /** + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testOrderByAndLimitAreAppliedToTheQuery($function): void { + $total = 0; + $function('', 'td.id DESC', '0,30', $total, -1, 0); + + $call = CactiStubs::callsTo('db_fetch_assoc_prepared')[0]; + + $this->assertStringContainsString('ORDER BY td.id DESC', $call['sql']); + $this->assertStringContainsString('LIMIT 0,30', $call['sql']); + } + + /** + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testResultRowsAreReturnedToTheCaller($function): void { + CactiStubs::willReturn('db_fetch_assoc_prepared', [['id' => 5]]); + + $total = 0; + $rows = $function('', 'td.name', '', $total, -1, 0); + + $this->assertSame([['id' => 5]], $rows); + } + + /** + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testTotalRowsIsSetByReference($function): void { + CactiStubs::willReturn('db_fetch_cell_prepared', 17); + + $total = 0; + $function('', 'td.name', '', $total, -1, 3); + + $this->assertSame(17, $total); + } + + /** + * With authentication on and no session, there is no user to resolve + * permissions against, so the query must not run at all. + * + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testNoQueryRunsWhenAuthenticationIsOnAndNoUserIsResolved($function): void { + CactiStubs::$configOptions['auth_method'] = 1; + unset($_SESSION['sess_user_id']); + + $total = 0; + $rows = $function('', 'td.name', '', $total, 0, 0); + + $this->assertSame([], $rows); + $this->assertSame([], CactiStubs::callsTo('db_fetch_assoc_prepared')); + } + + /** + * @dataProvider accessorProvider + * + * @param string $function + * + * @return void + */ + public function testPolicyWhereIsAppliedWhenPermissionsAreNotSimple($function): void { + CactiStubs::$configOptions['auth_method'] = 1; + CactiStubs::willReturn('get_simple_graph_perms', false); + CactiStubs::willReturn('get_policy_where', 'WHERE policy_applied = 1'); + $_SESSION['sess_user_id'] = 9; + + $total = 0; + $function('', 'td.name', '', $total, 0, 0); + + unset($_SESSION['sess_user_id']); + + $call = CactiStubs::callsTo('db_fetch_assoc_prepared')[0]; + + $this->assertStringContainsString('policy_applied = 1', $call['sql']); + } +} diff --git a/tests/Unit/TholdCalculateLowerUpperTest.php b/tests/Unit/TholdCalculateLowerUpperTest.php new file mode 100644 index 00000000..b76ba75b --- /dev/null +++ b/tests/Unit/TholdCalculateLowerUpperTest.php @@ -0,0 +1,68 @@ + 'octets_hi', 'local_data_id' => 4]; + $rrd = [4 => ['octets_hi' => 2]]; + + $this->assertSame((2 << 32) + 100, thold_calculate_lower_upper($thold, 100, $rrd)); + } + + /** + * @return void + */ + public function testValuePassesThroughWhenTheHighWordIsAbsent(): void { + $thold = ['upper_ds' => 'octets_hi', 'local_data_id' => 4]; + $rrd = [4 => ['octets_lo' => 5]]; + + $this->assertSame(100, thold_calculate_lower_upper($thold, 100, $rrd)); + } + + /** + * @return void + */ + public function testValuePassesThroughWhenTheDataSourceHasNoReadings(): void { + $thold = ['upper_ds' => 'octets_hi', 'local_data_id' => 4]; + + $this->assertSame(100, thold_calculate_lower_upper($thold, 100, [])); + } + + /** + * @return void + */ + public function testHighWordOfZeroLeavesTheValueUnchanged(): void { + $thold = ['upper_ds' => 'octets_hi', 'local_data_id' => 4]; + $rrd = [4 => ['octets_hi' => 0]]; + + $this->assertSame(100, thold_calculate_lower_upper($thold, 100, $rrd)); + } +} diff --git a/tests/Unit/TholdCalculatePercentTest.php b/tests/Unit/TholdCalculatePercentTest.php new file mode 100644 index 00000000..badc058c --- /dev/null +++ b/tests/Unit/TholdCalculatePercentTest.php @@ -0,0 +1,83 @@ + + */ + private function threshold() { + return ['percent_ds' => 'total', 'local_data_id' => 4]; + } + + /** + * @return void + */ + public function testReadingIsExpressedAsAPercentageOfTheReferenceDataSource(): void { + $rrd = [4 => ['total' => 200]]; + + $this->assertSame(25.0, thold_calculate_percent($this->threshold(), 50, $rrd)); + } + + /** + * @return void + */ + public function testNonNumericReadingYieldsTheNoValueSentinel(): void { + $rrd = [4 => ['total' => 200]]; + + $this->assertSame('', thold_calculate_percent($this->threshold(), 'U', $rrd)); + } + + /** + * @return void + */ + public function testMissingReferenceDataSourceYieldsTheNoValueSentinel(): void { + $rrd = [4 => ['other' => 200]]; + + $this->assertSame('', thold_calculate_percent($this->threshold(), 50, $rrd)); + } + + /** + * @return void + */ + public function testZeroReferenceYieldsZeroRatherThanDividingByZero(): void { + $rrd = [4 => ['total' => 0]]; + + $this->assertSame(0, thold_calculate_percent($this->threshold(), 50, $rrd)); + } + + /** + * @return void + */ + public function testNegativeReferenceYieldsZero(): void { + $rrd = [4 => ['total' => -5]]; + + $this->assertSame(0, thold_calculate_percent($this->threshold(), 50, $rrd)); + } +} diff --git a/tests/Unit/TholdCommandExecutionTest.php b/tests/Unit/TholdCommandExecutionTest.php new file mode 100644 index 00000000..d0f3e648 --- /dev/null +++ b/tests/Unit/TholdCommandExecutionTest.php @@ -0,0 +1,250 @@ + + */ + private function threshold(array $overrides = []) { + return $overrides + [ + 'id' => 3, + 'local_data_id' => 4, + 'local_graph_id' => 7, + 'name' => 'CPU', + 'name_cache' => 'CPU', + 'data_source_name' => 'traffic_in', + 'lastread' => 95, + 'notes' => '', + 'dnotes' => '', + 'external_id' => '', + 'thold_type' => 0, + 'thold_hi' => 90, + 'thold_low' => 10, + 'thold_fail_trigger' => 3, + 'thold_template_id' => 0, + 'trigger_cmd_high' => '', + 'trigger_cmd_low' => '', + 'trigger_cmd_norm' => '', + ]; + } + + /** + * @return array + */ + private function device(array $overrides = []) { + return $overrides + [ + 'id' => 2, + 'description' => 'router1', + 'hostname' => '10.0.0.1', + 'location' => 'rack 4', + 'site_id' => 1, + 'status' => 3, + 'status_fail_date' => '2026-01-01 00:00:00', + 'status_rec_date' => '2026-01-02 00:00:00', + 'status_last_error' => '', + ]; + } + + /** + * The queued command, or null when nothing was queued. + * + * @return string|null + */ + private function queuedCommand() { + foreach (CactiStubs::callsTo('db_execute_prepared') as $call) { + foreach ($call['params'] as $param) { + if (is_string($param) && strpos($param, '"command"') !== false) { + $decoded = json_decode($param, true); + + return isset($decoded['command']) ? $decoded['command'] : null; + } + } + } + + return null; + } + + /** + * @return array}> + */ + public static function breachDirectionProvider() { + return [ + 'high' => ['trigger_cmd_high', [true, false, false]], + 'low' => ['trigger_cmd_low', [false, true, false]], + 'restore' => ['trigger_cmd_norm', [false, false, true]], + ]; + } + + /** + * @dataProvider breachDirectionProvider + * + * @param string $column + * @param array $breaches + * + * @return void + */ + public function testEachBreachDirectionRunsItsOwnCommand($column, array $breaches): void { + $thold = $this->threshold([$column => '/usr/bin/alert ']); + $device = $this->device(); + + thold_command_execution($thold, $device, $breaches[0], $breaches[1], $breaches[2]); + + $this->assertSame("/usr/bin/alert '10.0.0.1'", $this->queuedCommand()); + } + + /** + * @dataProvider breachDirectionProvider + * + * @param string $column + * @param array $breaches + * + * @return void + */ + public function testShellMetacharactersInDeviceDataAreQuoted($column, array $breaches): void { + $thold = $this->threshold([$column => '/usr/bin/alert ']); + $device = $this->device(['description' => '; touch /tmp/pwned']); + + thold_command_execution($thold, $device, $breaches[0], $breaches[1], $breaches[2]); + + $this->assertSame("/usr/bin/alert '; touch /tmp/pwned'", $this->queuedCommand()); + } + + /** + * @return void + */ + public function testNothingRunsWhenScriptsAreDisabled(): void { + CactiStubs::$configOptions['thold_enable_scripts'] = ''; + + $thold = $this->threshold(['trigger_cmd_high' => '/usr/bin/alert']); + $device = $this->device(); + + thold_command_execution($thold, $device, true, false, false); + + $this->assertNull($this->queuedCommand()); + } + + /** + * @return void + */ + public function testNothingRunsWhenTheDirectionHasNoCommandConfigured(): void { + $thold = $this->threshold(); + $device = $this->device(); + + thold_command_execution($thold, $device, true, false, false); + + $this->assertNull($this->queuedCommand()); + } + + /** + * A high breach takes precedence, so a threshold configured for both cannot + * run two commands in one evaluation. + * + * @return void + */ + public function testHighBreachTakesPrecedenceOverLow(): void { + $thold = $this->threshold([ + 'trigger_cmd_high' => '/usr/bin/high', + 'trigger_cmd_low' => '/usr/bin/low', + ]); + $device = $this->device(); + + thold_command_execution($thold, $device, true, true, false); + + $this->assertSame('/usr/bin/high', $this->queuedCommand()); + } + + /** + * With the queue off the command runs inline, and its exit status and + * output are logged. + * + * @dataProvider breachDirectionProvider + * + * @param string $column + * @param array $breaches + * + * @return void + */ + public function testInlineExecutionLogsTheCommandOutput($column, array $breaches): void { + CactiStubs::$configOptions['thold_notification_queue'] = ''; + + $thold = $this->threshold([$column => '/bin/echo breach']); + $device = $this->device(); + + thold_command_execution($thold, $device, $breaches[0], $breaches[1], $breaches[2]); + + $this->assertNull($this->queuedCommand()); + $this->assertNotEmpty(CactiStubs::$log); + } + + /** + * @return array + */ + public static function inlineOutcomeProvider() { + return [ + 'success without output' => ['/bin/true', 'NOTE'], + 'success with output' => ['/bin/echo ok', 'NOTE'], + 'failure without output' => ['/bin/false', 'WARNING'], + 'failure with output' => ["/bin/sh -c 'echo oops; exit 1'", 'WARNING'], + ]; + } + + /** + * A trigger command that fails is the operator's only signal that their + * alerting is broken, so the exit status has to reach the log either way. + * + * @dataProvider inlineOutcomeProvider + * + * @param string $command + * @param string $level + * + * @return void + */ + public function testInlineExecutionLogsTheExitStatus($command, $level): void { + CactiStubs::$configOptions['thold_notification_queue'] = ''; + + $thold = $this->threshold(['trigger_cmd_high' => $command]); + $device = $this->device(); + + thold_command_execution($thold, $device, true, false, false); + + $this->assertStringStartsWith($level, CactiStubs::$log[0]); + } +} diff --git a/tests/Unit/TholdExpandStringTest.php b/tests/Unit/TholdExpandStringTest.php new file mode 100644 index 00000000..8f1415d8 --- /dev/null +++ b/tests/Unit/TholdExpandStringTest.php @@ -0,0 +1,140 @@ + escaping rather than after. + */ +final class TholdExpandStringTest extends TestCase { + /** + * @return void + */ + public static function setUpBeforeClass(): void { + self::loadPluginSource('thold_functions.php'); + } + + /** + * @return array + */ + private function thresholdData(array $overrides = []) { + return $overrides + [ + 'local_graph_id' => 7, + 'local_data_id' => 4, + 'data_source_name' => 'traffic_in', + 'thold_template_id' => 0, + ]; + } + + /** + * @return void + */ + private function graphExists() { + CactiStubs::willReturn('db_fetch_row_prepared', [ + 'id' => 7, + 'host_id' => 2, + 'snmp_query_id' => 3, + 'snmp_index' => '1', + ]); + } + + /** + * @return void + */ + public function testGraphTitleTokenIsResolved(): void { + $this->graphExists(); + + $this->assertSame('Traffic - eth0', thold_expand_string($this->thresholdData(), '|graph_title|')); + } + + /** + * @return void + */ + public function testDataSourceNameTokenIsResolved(): void { + $this->graphExists(); + + $this->assertSame('traffic_in', thold_expand_string($this->thresholdData(), '|data_source_name|')); + } + + /** + * @return void + */ + public function testDataSourceDescriptionTokenIsResolvedFromTheDatabase(): void { + $this->graphExists(); + CactiStubs::willReturn('db_fetch_cell_prepared', 'Router - Traffic'); + + $this->assertSame('Router - Traffic', thold_expand_string($this->thresholdData(), '|data_source_description|')); + } + + /** + * @return void + */ + public function testTextIsPassedThroughExpandTitleForDataQueryTokens(): void { + $this->graphExists(); + CactiStubs::willReturn('expand_title', 'alert eth0'); + + $this->assertSame('alert eth0', thold_expand_string($this->thresholdData(), 'alert |query_ifName|')); + $this->assertNotEmpty(CactiStubs::callsTo('expand_title')); + } + + /** + * @return void + */ + public function testInterfaceSpeedFallsBackToTheConfiguredDefaultWhenUnknown(): void { + $this->graphExists(); + CactiStubs::$configOptions['thold_empty_if_speed_default'] = '1000000000'; + CactiStubs::willReturn('db_fetch_cell_prepared', ''); + + $result = thold_expand_string($this->thresholdData(), '|query_ifHighSpeed|'); + + $this->assertStringNotContainsString('|query_ifHighSpeed|', $result); + } + + /** + * @return void + */ + public function testTextIsReturnedUnchangedWhenTheGraphIsMissing(): void { + CactiStubs::willReturn('db_fetch_row_prepared', []); + + $this->assertSame('static text', thold_expand_string($this->thresholdData(), 'static text')); + } + + /** + * An empty template falls back to the threshold template's suggested name, + * which is itself a token string and gets expanded in turn. + * + * @return void + */ + public function testEmptyStringFallsBackToTheExpandedTemplateSuggestedName(): void { + $this->graphExists(); + CactiStubs::willReturn('db_fetch_cell_prepared', 'Suggested |data_source_name|'); + + $result = thold_expand_string($this->thresholdData(['thold_template_id' => 5]), ''); + + $this->assertSame('Suggested traffic_in', $result); + } + + /** + * @return void + */ + public function testSurroundingWhitespaceIsTrimmed(): void { + CactiStubs::willReturn('db_fetch_row_prepared', []); + + $this->assertSame('alert', thold_expand_string($this->thresholdData(), ' alert ')); + } +} diff --git a/tests/Unit/TholdExpressionMathRpnTest.php b/tests/Unit/TholdExpressionMathRpnTest.php new file mode 100644 index 00000000..6c9fdbf1 --- /dev/null +++ b/tests/Unit/TholdExpressionMathRpnTest.php @@ -0,0 +1,282 @@ + $stack Initial stack, bottom element first. + * @param string $operator RPN operator token. + * + * @return array + */ + private function evaluate(array $stack, $operator) { + thold_expression_math_rpn($operator, $stack); + + return $stack; + } + + /** + * @return array, 1: string, 2: float|int}> + */ + public static function binaryOperatorProvider() { + return [ + 'addition' => [[8, 2], '+', 10], + 'subtraction keeps order' => [[8, 2], '-', 6], + 'multiplication' => [[8, 2], '*', 16], + 'division keeps order' => [[8, 2], '/', 4], + 'modulo' => [[8, 3], '%', 2], + 'float addition' => [[1.5, 2.25], '+', 3.75], + 'numeric string operands' => [['8', '2'], '-', 6], + 'negative operands' => [[-8, 2], '/', -4], + ]; + } + + /** + * @dataProvider binaryOperatorProvider + * + * @param array $stack + * @param string $operator + * @param float|int $expected + * + * @return void + */ + public function testBinaryOperatorsComputeInStackOrder(array $stack, $operator, $expected): void { + $this->assertSame([$expected], $this->evaluate($stack, $operator)); + $this->assertFalse($GLOBALS['rpn_error']); + } + + /** + * PHP's ^ is bitwise XOR, not exponentiation. The pre-hardening evaluator + * built the string "$v2 ^ $v1" and eval'd it, so it computed XOR too. This + * test records the behaviour as-is; changing it to pow() would be a + * separate, breaking change to existing user thresholds. + * + * @return void + */ + public function testCaretOperatorIsIntegerXorNotExponentiation(): void { + $this->assertSame([6], $this->evaluate([5, 3], '^')); + $this->assertSame([1], $this->evaluate([2, 3], '^')); + } + + /** + * @return void + */ + public function testCaretOperatorTruncatesFloatOperandsToIntegers(): void { + $this->assertSame([6], $this->evaluate([5.9, 3.9], '^')); + } + + /** + * @return void + */ + public function testModuloTruncatesFloatOperandsToIntegers(): void { + $this->assertSame([1], $this->evaluate([7.9, 3.2], '%')); + } + + /** + * Dividing zero by zero is defined as zero rather than an error so a + * counter that has not moved does not flag the whole threshold. + * + * @return void + */ + public function testZeroDividedByZeroYieldsZeroWithoutError(): void { + $this->assertSame([0], $this->evaluate([0, 0], '/')); + $this->assertFalse($GLOBALS['rpn_error']); + } + + /** + * @return void + */ + public function testDivisionByZeroFlagsErrorAndPushesNothing(): void { + $this->assertSame([], $this->evaluate([8, 0], '/')); + $this->assertTrue($GLOBALS['rpn_error']); + } + + /** + * @return void + */ + public function testModuloByZeroFlagsErrorInsteadOfThrowing(): void { + $this->assertSame([], $this->evaluate([8, 0], '%')); + $this->assertTrue($GLOBALS['rpn_error']); + } + + /** + * @return array, 1: string}> + */ + public static function nonNumericOperandProvider() { + return [ + 'unknown right operand' => [[8, 'U'], '+'], + 'unknown left operand' => [['U', 8], '+'], + 'NaN right operand' => [[8, 'NAN'], '*'], + 'text operand' => [[8, 'abc'], '-'], + ]; + } + + /** + * @dataProvider nonNumericOperandProvider + * + * @param array $stack + * @param string $operator + * + * @return void + */ + public function testNonNumericOperandsFlagErrorAndPushNothing(array $stack, $operator): void { + $this->assertSame([], $this->evaluate($stack, $operator)); + $this->assertTrue($GLOBALS['rpn_error']); + $this->assertNotEmpty(CactiStubs::$log); + } + + /** + * @return array + */ + public static function unaryFunctionProvider() { + return [ + 'SIN' => [0, 'SIN', 0.0], + 'COS' => [0, 'COS', 1.0], + 'TAN' => [0, 'TAN', 0.0], + 'ATAN' => [0, 'ATAN', 0.0], + 'SQRT' => [9, 'SQRT', 3.0], + 'FLOOR' => [2.7, 'FLOOR', 2.0], + 'CEIL' => [2.1, 'CEIL', 3.0], + 'DEG2RAD' => [180, 'DEG2RAD', M_PI], + 'RAD2DEG' => [M_PI, 'RAD2DEG', 180.0], + 'ABS' => [-5, 'ABS', 5], + 'EXP' => [0, 'EXP', 1.0], + 'LOG' => [M_E, 'LOG', 1.0], + ]; + } + + /** + * @dataProvider unaryFunctionProvider + * + * @param float|int $operand + * @param string $operator + * @param float|int $expected + * + * @return void + */ + public function testUnaryFunctionsDispatchToNativeMath($operand, $operator, $expected): void { + $stack = $this->evaluate([$operand], $operator); + + $this->assertCount(1, $stack); + $this->assertEqualsWithDelta($expected, $stack[0], 1.0e-9); + $this->assertFalse($GLOBALS['rpn_error']); + } + + /** + * @return void + */ + public function testUnaryFunctionRejectsNonNumericOperand(): void { + $this->assertSame([], $this->evaluate(['U'], 'SQRT')); + $this->assertTrue($GLOBALS['rpn_error']); + } + + /** + * sqrt(-1) and log(0) have no usable numeric result, so the evaluator must + * flag an error rather than push NAN or -INF into a threshold comparison, + * where every comparison against them silently returns false. + * + * @return array + */ + public static function undefinedResultProvider() { + return [ + 'square root of a negative' => [-1, 'SQRT'], + 'log of zero' => [0, 'LOG'], + 'log of a negative' => [-1, 'LOG'], + ]; + } + + /** + * @dataProvider undefinedResultProvider + * + * @param float|int $operand + * @param string $operator + * + * @return void + */ + public function testUndefinedResultsFlagErrorInsteadOfPushingNanOrInf($operand, $operator): void { + $this->assertSame([], $this->evaluate([$operand], $operator)); + $this->assertTrue($GLOBALS['rpn_error']); + } + + /** + * @return void + */ + public function testAtan2ComputesAgainstBothOperands(): void { + $stack = $this->evaluate([1, 1], 'ATAN2'); + + $this->assertEqualsWithDelta(M_PI / 4, $stack[0], 1.0e-9); + } + + /** + * ADDNAN exists so a missing sample contributes zero instead of poisoning + * the sum. + * + * @return array, 1: float|int}> + */ + public static function addNanProvider() { + return [ + 'both known' => [[3, 4], 7], + 'right unknown' => [[3, 'U'], 3], + 'left unknown' => [['U', 4], 4], + 'right NaN' => [[3, 'NAN'], 3], + 'both unknown' => [['U', 'NAN'], 0], + ]; + } + + /** + * @dataProvider addNanProvider + * + * @param array $stack + * @param float|int $expected + * + * @return void + */ + public function testAddNanTreatsUnknownOperandsAsZero(array $stack, $expected): void { + $this->assertSame([$expected], $this->evaluate($stack, 'ADDNAN')); + } + + /** + * @return void + */ + public function testUnknownOperatorLeavesStackUntouched(): void { + $this->assertSame([1, 2], $this->evaluate([1, 2], 'NOSUCHOP')); + } + + /** + * @return void + */ + public function testUnderflowFlagsErrorRatherThanPoppingAnEmptyStack(): void { + $this->evaluate([], '+'); + + $this->assertTrue($GLOBALS['rpn_error']); + } +} diff --git a/tests/Unit/TholdGetCachedNameTest.php b/tests/Unit/TholdGetCachedNameTest.php new file mode 100644 index 00000000..a33d83ce --- /dev/null +++ b/tests/Unit/TholdGetCachedNameTest.php @@ -0,0 +1,59 @@ + '|data_source_description|', 'name_cache' => 'CPU load', 'local_data_id' => 4]; + + $this->assertSame('CPU load', thold_get_cached_name($thold)); + $this->assertSame([], CactiStubs::callsTo('db_fetch_cell_prepared')); + } + + /** + * @return void + */ + public function testEmptyCacheIsFilledFromTheDataSourceDescription(): void { + CactiStubs::willReturn('db_fetch_cell_prepared', 'Router - Traffic'); + + $thold = ['name' => '|data_source_description|', 'name_cache' => '', 'local_data_id' => 4]; + + $this->assertSame('Router - Traffic', thold_get_cached_name($thold)); + $this->assertSame('Router - Traffic', $thold['name_cache']); + } + + /** + * @return void + */ + public function testNameIsKeptWhenTheDataSourceHasNoDescription(): void { + $thold = ['name' => 'Manual name', 'name_cache' => '', 'local_data_id' => 4]; + + $this->assertSame('Manual name', thold_get_cached_name($thold)); + } +} diff --git a/tests/Unit/TholdReplaceThresholdTagsTest.php b/tests/Unit/TholdReplaceThresholdTagsTest.php new file mode 100644 index 00000000..96006c94 --- /dev/null +++ b/tests/Unit/TholdReplaceThresholdTagsTest.php @@ -0,0 +1,274 @@ + substitution reads. + self::loadPluginSource('includes/arrays.php'); + } + + /** + * @return array + */ + private function threshold(array $overrides = []) { + return $overrides + [ + 'id' => 1, + 'name_cache' => 'CPU', + 'notes' => '', + 'dnotes' => '', + 'external_id' => '', + 'thold_type' => 0, + 'thold_hi' => 90, + 'thold_low' => 10, + 'thold_fail_trigger' => 3, + 'time_hi' => 80, + 'time_low' => 20, + 'time_fail_trigger' => 2, + 'time_fail_length' => 300, + 'local_data_id' => 4, + ]; + } + + /** + * @return array + */ + private function device(array $overrides = []) { + return $overrides + [ + 'description' => 'router1', + 'hostname' => '10.0.0.1', + 'location' => 'rack 4', + 'site_id' => 1, + ]; + } + + /** + * Substitute tags into $text. + * + * @param string $text + * @param array $thold + * @param array $device + * @param bool $shell + * @param mixed $currentval + * + * @return string + */ + private function substitute($text, array $thold, array $device, $shell, $currentval = 42) { + return thold_replace_threshold_tags($text, $thold, $device, $currentval, 7, 'traffic_in', $shell); + } + + /** + * @return array + */ + public static function deviceDerivedTagProvider() { + return [ + 'description' => ['', 'description', 'device'], + 'hostname' => ['', 'hostname', 'device'], + 'location' => ['', 'location', 'device'], + 'notes' => ['', 'notes', 'threshold'], + 'device note' => ['', 'dnotes', 'threshold'], + 'external id' => ['', 'external_id', 'threshold'], + 'name' => ['', 'name_cache', 'threshold'], + ]; + } + + /** + * @dataProvider deviceDerivedTagProvider + * + * @param string $tag + * @param string $column + * @param string $source + * + * @return void + */ + public function testShellModeQuotesEveryDeviceDerivedTag($tag, $column, $source): void { + $payload = '; touch /tmp/pwned'; + $thold = $this->threshold($source === 'threshold' ? [$column => $payload] : []); + $device = $this->device($source === 'device' ? [$column => $payload] : []); + + $result = $this->substitute("/usr/bin/alert $tag", $thold, $device, true); + + $this->assertStringContainsString(escapeshellarg($payload), $result); + $this->assertStringNotContainsString('alert ; touch', $result); + } + + /** + * @dataProvider deviceDerivedTagProvider + * + * @param string $tag + * @param string $column + * @param string $source + * + * @return void + */ + public function testEmailModeLeavesDeviceDerivedTagsUnquoted($tag, $column, $source): void { + $thold = $this->threshold($source === 'threshold' ? [$column => "O'Brien"] : []); + $device = $this->device($source === 'device' ? [$column => "O'Brien"] : []); + + $result = $this->substitute("Alert on $tag", $thold, $device, false); + + $this->assertStringContainsString("O'Brien", $result); + $this->assertStringNotContainsString("'O'\\''Brien'", $result); + } + + /** + * The site name comes from the sites table, which an operator edits, so it + * needs the same treatment as the device columns. + * + * @return void + */ + public function testShellModeQuotesTheSiteName(): void { + CactiStubs::willReturn('db_fetch_cell_prepared', '$(id)'); + + $result = $this->substitute('/usr/bin/alert ', $this->threshold(), $this->device(), true); + + $this->assertStringContainsString(escapeshellarg('$(id)'), $result); + } + + /** + * @return void + */ + public function testSiteFallsBackToDefaultWhenTheDeviceHasNoSite(): void { + CactiStubs::willReturn('db_fetch_cell_prepared', ''); + + $result = $this->substitute('site=', $this->threshold(), $this->device(), false); + + $this->assertSame('site=Default', $result); + } + + /** + * The current reading is an RRD value rather than a number in every case; + * it must not be able to extend the command line either. + * + * @return void + */ + public function testShellModeQuotesTheCurrentValue(): void { + $result = $this->substitute('/usr/bin/alert ', $this->threshold(), $this->device(), true, '; id'); + + $this->assertStringContainsString(escapeshellarg('; id'), $result); + $this->assertStringNotContainsString('alert ; id', $result); + } + + /** + * @return void + */ + public function testEmailModeLeavesTheCurrentValueUnquoted(): void { + $result = $this->substitute('value=', $this->threshold(), $this->device(), false, 42); + + $this->assertSame('value=42', $result); + } + + /** + * @return void + */ + public function testGraphAndThresholdIdentifiersAreSubstituted(): void { + $result = $this->substitute('/', $this->threshold(['id' => 5]), $this->device(), false); + + $this->assertSame('7/5', $result); + } + + /** + * @return void + */ + public function testStaticThresholdBoundsAreSubstituted(): void { + $result = $this->substitute('//', $this->threshold(), $this->device(), false); + + $this->assertSame('90/10/3', $result); + } + + /** + * A time-based threshold reports its own bounds and a duration rather than + * the static ones. + * + * @return void + */ + public function testTimeBasedThresholdSubstitutesTheTimeBounds(): void { + $result = $this->substitute('[][][]', $this->threshold(['thold_type' => 2]), $this->device(), false); + + $this->assertSame('[80][20][2]', $result); + } + + /** + * A baseline threshold has neither static nor time bounds, so the tags + * resolve to empty rather than being left in the output. + * + * @return void + */ + public function testBaselineThresholdClearsTheBoundTags(): void { + $result = $this->substitute('[][][][]', $this->threshold(['thold_type' => 1]), $this->device(), false); + + $this->assertSame('[][][][]', $result); + } + + /** + * @return void + */ + public function testStaticThresholdHasNoDuration(): void { + $result = $this->substitute('[]', $this->threshold(), $this->device(), false); + + $this->assertSame('[]', $result); + } + + /** + * @return void + */ + public function testUrlTagRendersALinkToTheGraph(): void { + CactiStubs::$configOptions['base_url'] = 'http://cacti.example.org'; + + $result = $this->substitute('', $this->threshold(), $this->device(), false); + + $this->assertStringContainsString('graph.php?local_graph_id=7', $result); + } + + /** + * @return void + */ + public function testThresholdTypeNameIsSubstituted(): void { + $result = $this->substitute('', $this->threshold(), $this->device(), false); + + $this->assertSame('High / Low', $result); + } + + /** + * @return void + */ + public function testUnknownThresholdTypeLeavesTheTagInPlace(): void { + $result = $this->substitute('', $this->threshold(['thold_type' => 99]), $this->device(), false); + + $this->assertSame('', $result); + } + + /** + * @return void + */ + public function testTextWithoutTagsIsReturnedUnchanged(): void { + $result = $this->substitute('nothing to replace', $this->threshold(), $this->device(), true); + + $this->assertSame('nothing to replace', $result); + } +} diff --git a/tests/Unit/TholdRlikeClauseTest.php b/tests/Unit/TholdRlikeClauseTest.php new file mode 100644 index 00000000..c32a6421 --- /dev/null +++ b/tests/Unit/TholdRlikeClauseTest.php @@ -0,0 +1,70 @@ +assertSame("RLIKE 'router'", thold_rlike_clause('router')); + } + + /** + * A quote in the filter has to be escaped, or it closes the string literal + * and the rest of the filter becomes SQL. + * + * @return void + */ + public function testQuotesInTheFilterAreEscaped(): void { + $clause = thold_rlike_clause("' OR 1=1 -- "); + + $this->assertSame("RLIKE ''' OR 1=1 -- '", $clause); + } + + /** + * @return void + */ + public function testEmptyFilterStillProducesAValidClause(): void { + $this->assertSame("RLIKE ''", thold_rlike_clause('')); + } + + /** + * @return void + */ + public function testCoreHelperIsPreferredWhenAvailable(): void { + if (!function_exists('db_qstr_rlike')) { + $this->assertSame("RLIKE 'x'", thold_rlike_clause('x')); + + return; + } + + $this->assertSame(db_qstr_rlike('x'), thold_rlike_clause('x')); + } +} diff --git a/tests/Unit/TholdSetEnvironTest.php b/tests/Unit/TholdSetEnvironTest.php new file mode 100644 index 00000000..02ac28e2 --- /dev/null +++ b/tests/Unit/TholdSetEnvironTest.php @@ -0,0 +1,215 @@ + + */ + private function threshold(array $overrides = []) { + return $overrides + [ + 'id' => 3, + 'local_data_id' => 4, + 'local_graph_id' => 7, + 'name_cache' => 'CPU', + 'notes' => '', + 'dnotes' => 'device note', + 'external_id' => '', + 'thold_type' => 0, + 'thold_hi' => 90, + 'thold_low' => 10, + 'thold_fail_trigger' => 3, + 'time_hi' => 80, + 'time_low' => 20, + 'time_fail_trigger' => 2, + 'time_fail_length' => 300, + ]; + } + + /** + * @return array + */ + private function device(array $overrides = []) { + return $overrides + [ + 'description' => 'router1', + 'hostname' => '10.0.0.1', + 'location' => 'rack 4', + 'site_id' => 1, + 'status' => 3, + 'status_fail_date' => '2026-01-01 00:00:00', + 'status_rec_date' => '2026-01-02 00:00:00', + 'status_last_error' => '', + ]; + } + + /** + * Collect the exported environment as a name => value map. + * + * @param array $thold + * @param array $device + * + * @return array + */ + private function environment(array $thold, array $device) { + $pairs = thold_set_environ('', $thold, $device, 42, 7, 'traffic_in'); + $map = []; + + foreach ($pairs as $pair) { + [$name, $value] = explode('=', $pair, 2); + + $map[$name] = $value; + } + + return $map; + } + + /** + * @return void + */ + public function testThresholdAndDeviceContextIsExported(): void { + $env = $this->environment($this->threshold(), $this->device()); + + $this->assertSame('3', $env['THOLD_ID']); + $this->assertSame('router1', $env['THOLD_DESCRIPTION']); + $this->assertSame('10.0.0.1', $env['THOLD_HOSTNAME']); + $this->assertSame('42', $env['THOLD_CURRENTVALUE']); + $this->assertSame('traffic_in', $env['THOLD_DSNAME']); + $this->assertSame('device note', $env['THOLD_DEVICENOTE']); + } + + /** + * The queue accumulates across calls, so the first pair has to reset it or + * a later command inherits the previous threshold's context. + * + * @return void + */ + public function testEachCallStartsFromAnEmptyEnvironment(): void { + $this->environment($this->threshold(), $this->device()); + $env = $this->environment($this->threshold(['id' => 9]), $this->device()); + + $this->assertSame('9', $env['THOLD_ID']); + $this->assertCount(1, array_keys(array_filter(array_keys($env), function ($name) { + return $name === 'THOLD_ID'; + }))); + } + + /** + * @return void + */ + public function testStaticThresholdExportsItsBoundsAndNoDuration(): void { + $env = $this->environment($this->threshold(), $this->device()); + + $this->assertSame('90', $env['THOLD_HI']); + $this->assertSame('10', $env['THOLD_LOW']); + $this->assertSame('3', $env['THOLD_TRIGGER']); + $this->assertSame('', $env['THOLD_DURATION']); + } + + /** + * @return void + */ + public function testTimeBasedThresholdExportsTheTimeBoundsAndADuration(): void { + $env = $this->environment($this->threshold(['thold_type' => 2]), $this->device()); + + $this->assertSame('80', $env['THOLD_HI']); + $this->assertSame('20', $env['THOLD_LOW']); + $this->assertSame('2', $env['THOLD_TRIGGER']); + $this->assertNotSame('', $env['THOLD_DURATION']); + } + + /** + * @return void + */ + public function testBaselineThresholdExportsEmptyBounds(): void { + $env = $this->environment($this->threshold(['thold_type' => 1]), $this->device()); + + $this->assertSame('', $env['THOLD_HI']); + $this->assertSame('', $env['THOLD_LOW']); + $this->assertSame('', $env['THOLD_TRIGGER']); + $this->assertSame('', $env['THOLD_DURATION']); + } + + /** + * @return void + */ + public function testNotesAreTagExpandedWhenPresent(): void { + $env = $this->environment($this->threshold(['notes' => 'see ']), $this->device()); + + $this->assertSame('see 10.0.0.1', $env['THOLD_NOTES']); + } + + /** + * @return void + */ + public function testNotesAreEmptyWhenTheThresholdHasNone(): void { + $env = $this->environment($this->threshold(), $this->device()); + + $this->assertSame('', $env['THOLD_NOTES']); + } + + /** + * @return void + */ + public function testExternalIdIsExportedOnlyWhenSet(): void { + $env = $this->environment($this->threshold(), $this->device()); + $this->assertArrayNotHasKey('THOLD_EXTERNAL_ID', $env); + + $env = $this->environment($this->threshold(['external_id' => 'INC-42']), $this->device()); + $this->assertSame('INC-42', $env['THOLD_EXTERNAL_ID']); + } + + /** + * @return void + */ + public function testUnknownThresholdTypeExportsAnEmptyTypeName(): void { + $env = $this->environment($this->threshold(['thold_type' => 99]), $this->device()); + + $this->assertSame('', $env['THOLD_THOLDTYPE']); + } + + /** + * @return void + */ + public function testGraphUrlIsExported(): void { + $env = $this->environment($this->threshold(), $this->device()); + + $this->assertSame('http://cacti.example.org/graph.php?local_graph_id=7', $env['THOLD_URL']); + } +} diff --git a/tests/Unit/ZOptionalCoreFunctionTest.php b/tests/Unit/ZOptionalCoreFunctionTest.php new file mode 100644 index 00000000..1b3843bd --- /dev/null +++ b/tests/Unit/ZOptionalCoreFunctionTest.php @@ -0,0 +1,101 @@ +assertSame(db_qstr_rlike('router'), thold_rlike_clause('router')); + } + + /** + * Core's helper strips the alternation and quantifier characters that make + * a filter expensive to evaluate; the plugin must not bypass that. + * + * @return void + */ + public function testRlikeClauseInheritsTheCoreOperandRestrictions(): void { + $clause = thold_rlike_clause('(a|a){9,}'); + + $this->assertStringNotContainsString('|', $clause); + $this->assertStringNotContainsString('{', $clause); + } + + /** + * @return array + */ + public static function accessorProvider() { + return [ + 'thresholds' => ['get_allowed_thresholds', 'thold'], + 'logs' => ['get_allowed_threshold_logs', 'thold_log'], + ]; + } + + /** + * The cached row count is used only for the unfiltered listing; a per-graph + * query counts directly so the cache is not keyed per graph. + * + * @dataProvider accessorProvider + * + * @param string $function + * @param string $class + * + * @return void + */ + public function testRowCountUsesTheCoreCacheWhenNotFilteredByGraph($function, $class): void { + CactiStubs::willReturn('get_total_row_data', 12); + + $total = 0; + $function('', 'td.name', '', $total, -1, 0); + + $this->assertSame(12, $total); + $this->assertSame([], CactiStubs::callsTo('db_fetch_cell_prepared')); + } + + /** + * @dataProvider accessorProvider + * + * @param string $function + * @param string $class + * + * @return void + */ + public function testRowCountBypassesTheCacheForASingleGraph($function, $class): void { + $total = 0; + $function('', 'td.name', '', $total, -1, 5); + + $this->assertSame([], CactiStubs::callsTo('get_total_row_data')); + $this->assertNotEmpty(CactiStubs::callsTo('db_fetch_cell_prepared')); + } +} diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile new file mode 100644 index 00000000..518f7321 --- /dev/null +++ b/tests/docker/Dockerfile @@ -0,0 +1,29 @@ +# Test runner for the Thold plugin. +# +# Pinned to PHP 8.1 because that is the oldest interpreter the CI matrix +# covers; what passes here passes on 8.2-8.4. pcov rather than Xdebug: line +# coverage is the only debug feature the suite needs and pcov is far cheaper. +FROM php:8.1-cli-alpine@sha256:7949370448b0b4d9787776dc5968e0fd8d48763292344b5fbf21539441228a98 + +# git is needed by the changed-line coverage gate, which diffs against the +# base branch. +RUN apk add --no-cache git gmp-dev \ + && docker-php-ext-install gmp \ + && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \ + && pecl install pcov \ + && docker-php-ext-enable pcov \ + && apk del .build-deps + +COPY --from=composer:2@sha256:4d71c3c2109c61d5415544264b59ad4087e4c5b7244481723664138fd36d5040 /usr/bin/composer /usr/bin/composer + +# The plugin lives where Cacti would put it, because thold_functions.php +# resolves its own includes through $config['base_path'] . '/plugins/thold'. +# No network or database is involved; the Cacti framework functions themselves +# are stubbed in tests/bootstrap.php. +WORKDIR /cacti/plugins/thold + +ENV COMPOSER_ALLOW_SUPERUSER=1 \ + COMPOSER_NO_INTERACTION=1 \ + COMPOSER_CACHE_DIR=/tmp/composer-cache + +CMD ["sh", "-c", "composer install --no-progress --no-ansi && composer test"] diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml new file mode 100644 index 00000000..99d38b47 --- /dev/null +++ b/tests/docker/docker-compose.yml @@ -0,0 +1,15 @@ +# Local mirror of the unit-test CI job. `docker compose -f +# tests/docker/docker-compose.yml run --rm phpunit` runs exactly what CI runs. +services: + phpunit: + build: + context: . + dockerfile: Dockerfile + image: cacti-thold-test:php8.1 + working_dir: /cacti/plugins/thold + volumes: + - ../..:/cacti/plugins/thold + - composer-cache:/tmp/composer-cache + +volumes: + composer-cache: diff --git a/thold.php b/thold.php index 0bf86f46..e9c491c6 100644 --- a/thold.php +++ b/thold.php @@ -614,7 +614,7 @@ function list_tholds() { } if (get_request_var('rfilter') != '') { - $sql_where .= ($sql_where == '' ? '(' : ' AND ') . " td.name_cache RLIKE '" . get_request_var('rfilter') . "'"; + $sql_where .= ($sql_where == '' ? '(' : ' AND ') . ' td.name_cache ' . thold_rlike_clause(get_request_var('rfilter')); } if ($statefilter != '') { @@ -763,18 +763,18 @@ function list_tholds() { - '> + '>