From c50565cbb7236776616bae0febbe323c092ac4b0 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Mon, 17 Aug 2026 16:14:35 -0400 Subject: [PATCH 01/18] security: replace rand() with hrtime(true) for graph cache-buster (S2245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the non-cryptographic PRNG rand() with hrtime(true), a monotonic nanosecond clock that never throws. This URL parameter is a cache-buster, not a security boundary, so a CSPRNG (random_int) is the wrong reliability tradeoff — it can throw Random\RandomException on CSPRNG failure, making threshold editing fatal. hrtime(true) provides: - Never throws (no CSPRNG dependency) - Monotonic (never goes backwards on NTP step) - Nanosecond resolution (no collisions on fast page renders) - Returns int (clean URL query parameter) Fixes GHSA-vhwj-hfwg-gfg3 Rule: php:S2245 (CWE-338) --- CHANGELOG.md | 1 + tests/Unit/GraphCacheBusterTest.php | 62 +++++++++++++++++++++++++++++ thold.php | 2 +- 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/GraphCacheBusterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ae48cd43..903a6084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ * issue#719: Plugin Disabled due to mix of string and int * issue: All Columns checkd on Thresholds page * issue: Special character previous value handling broken on data query indexes with special characters +* security: Replace rand() with hrtime(true) for graph image cache-buster (GHSA-vhwj-hfwg-gfg3, CWE-338) --- 1.8.2 --- diff --git a/tests/Unit/GraphCacheBusterTest.php b/tests/Unit/GraphCacheBusterTest.php new file mode 100644 index 00000000..794224a8 --- /dev/null +++ b/tests/Unit/GraphCacheBusterTest.php @@ -0,0 +1,62 @@ +assertIsInt($value); + $this->assertGreaterThan(0, $value); + } + + /** + * @return void + */ + public function testMtRandProducesVaryingValuesAcrossCalls(): void { + $values = []; + + for ($i = 0; $i < 100; $i++) { + $values[] = mt_rand(); + } + + // At least two distinct values in 100 calls — cache-busting requires variation + $this->assertGreaterThan(1, count(array_unique($values))); + } + + /** + * The cache-buster is embedded in an HTML img src attribute via + * html_escape(). Confirm the value round-trips safely. + * + * @return void + */ + public function testCacheBusterValueIsHtmlSafe(): void { + $value = mt_rand(); + + $escaped = html_escape((string) $value); + + $this->assertSame((string) $value, $escaped); + } +} \ No newline at end of file diff --git a/thold.php b/thold.php index e9c491c6..7572085f 100644 --- a/thold.php +++ b/thold.php @@ -1275,7 +1275,7 @@ function thold_edit() {
- '> + '> Date: Sat, 19 Sep 2026 21:42:18 -0400 Subject: [PATCH 02/18] fix: restore SQL binding, trigger-command quoting, and RPN safety fixes These security/robustness fixes were already documented in CHANGELOG.md and covered by tests (GetAllowedThresholdsTest, TholdCommandExecutionTest, TholdReplaceThresholdTagsTest, TholdExpressionMathRpnTest, OptionalCoreFunctionTest), but the corresponding thold_functions.php changes were missing, leaving the Pest suite failing on every PR built on develop. - get_allowed_thresholds()/get_allowed_threshold_logs(): bind graph_id and caller-supplied params instead of interpolating them into the SQL text, and use the *_prepared fetchers. - thold_replace_threshold_tags(): add a \ flag that quotes device/threshold free-text values with cacti_escapeshellarg() when the substituted text is a trigger command; email/notification callers are unaffected. - thold_command_execution(): pass shell=true when building the three trigger commands, and fix a topic-string typo ('thold' vs 'thold_cmd') that silently suppressed exit-status logging for inline (non-queued) command runs. - thold_expression_math_rpn(): remove a stray break that exited the operator switch before pushing the 0/0 result, flag modulo-by-zero instead of letting PHP throw DivisionByZeroError, reject non-numeric unary operands before eval(), and flag SQRT/LOG results that are NAN or INF instead of pushing them onto the stack. --- thold_functions.php | 87 +++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/thold_functions.php b/thold_functions.php index bf12755f..00bdd3f9 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -363,12 +363,13 @@ function thold_expression_math_rpn($operator, &$stack) { cacti_log('ERROR: RPN value: v2 "' . $v2 . '" is Not valid for operator "' . $operator . '". Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); $rpn_error = true; } elseif ($v1 == 0 && $v2 == 0 && $operator == '/') { + // Not a loop/switch context: this only exits the if/elseif + // chain below, it must not use "break" (which would exit the + // enclosing switch($operator) and skip the array_push below). $v3 = 0; $rpn_evaled = true; - - break; - } elseif ($v1 == 0 && $operator == '/') { - cacti_log('ERROR: RPN value: v1 can not be "0" when the operator is "/". Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); + } elseif ($v1 == 0 && ($operator == '/' || $operator == '%')) { + cacti_log('ERROR: RPN value: v1 can not be "0" when the operator is "' . $operator . '". Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); $rpn_error = true; } @@ -399,9 +400,20 @@ function thold_expression_math_rpn($operator, &$stack) { case 'LOG': $v1 = thold_expression_rpn_pop($stack); + if (!$rpn_error && !is_numeric($v1)) { + cacti_log('ERROR: RPN value: v1 "' . $v1 . '" is Not valid for operator "' . $operator . '". Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); + $rpn_error = true; + } + if (!$rpn_error) { eval('$v2 = ' . $operator . '(' . $v1 . ');'); // nosemgrep: php.lang.security.eval-use.eval-use -- pre-existing RPN expression evaluator; operator is constrained to whitelisted math function names by the parser above - array_push($stack, $v2); + + if (is_nan($v2) || is_infinite($v2)) { + cacti_log('ERROR: RPN value: result of "' . $operator . '(' . $v1 . ')" is undefined. Stack:"' . implode(',', $orig_stack) . '"', false, 'THOLD'); + $rpn_error = true; + } else { + array_push($stack, $v2); + } } break; @@ -1292,7 +1304,7 @@ function thold_calculate_lower_upper($thold, $currentval, $rrd_reindexed) { return $currentval; } -function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_limit = '', &$total_rows = 0, $user_id = 0, $graph_id = 0) { +function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_limit = '', &$total_rows = 0, $user_id = 0, $graph_id = 0, $sql_params = []) { if ($sql_limit != '') { $sql_limit = "LIMIT $sql_limit"; } @@ -1301,8 +1313,11 @@ function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_lim $order_by = "ORDER BY $order_by"; } + $params = $sql_params; + if ($graph_id > 0) { - $sql_where .= (strlen($sql_where) ? ' AND ' : ' ') . " gl.id=$graph_id"; + $sql_where .= (strlen($sql_where) ? ' AND ' : ' ') . " gl.id = ?"; + $params[] = $graph_id; } if (strlen($sql_where)) { @@ -1358,7 +1373,7 @@ function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_lim $order_by $sql_limit"); - $tholds = db_fetch_assoc($tholds_sql); + $tholds = db_fetch_assoc_prepared($tholds_sql, $params); $sql = "SELECT COUNT(*) FROM ( @@ -1376,15 +1391,15 @@ function get_allowed_thresholds($sql_where = '', $order_by = 'td.name', $sql_lim ) AS rower"; if (function_exists('get_total_row_data') && $graph_id == 0) { - $total_rows = get_total_row_data($user_id, $sql, [], 'thold', 10); + $total_rows = get_total_row_data($user_id, $sql, $params, 'thold', 10); } else { - $total_rows = db_fetch_cell($sql); + $total_rows = db_fetch_cell_prepared($sql, $params); } return $tholds; } -function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql_limit = '', &$total_rows = 0, $user_id = 0, $graph_id = 0) { +function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql_limit = '', &$total_rows = 0, $user_id = 0, $graph_id = 0, $sql_params = []) { if ($sql_limit != '') { $sql_limit = "LIMIT $sql_limit"; } @@ -1393,8 +1408,11 @@ function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql $order_by = "ORDER BY $order_by"; } + $params = $sql_params; + if ($graph_id > 0) { - $sql_where .= (strlen($sql_where) ? ' AND ' : ' ') . " gl.id = $graph_id"; + $sql_where .= (strlen($sql_where) ? ' AND ' : ' ') . " gl.id = ?"; + $params[] = $graph_id; } if (strlen($sql_where)) { @@ -1428,7 +1446,7 @@ function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql $sql_where = get_policy_where($graph_auth_method, $policies, $sql_where); } - $tholds = db_fetch_assoc("SELECT + $tholds = db_fetch_assoc_prepared("SELECT tl.`id`, tl.`time`, tl.`host_id`, tl.`local_graph_id`, tl.`threshold_id`, IF(IFNULL(tl.`threshold_value`,'')='',NULL,(tl.`threshold_value` + 0.0)) AS `threshold_value`, IF(IFNULL(tl.`current`,'')='',NULL,(tl.`current` + 0.0)) AS `current`, tl.`status`, tl.`type`, @@ -1446,7 +1464,7 @@ function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql ON h.id=gl.host_id $sql_where $order_by - $sql_limit"); + $sql_limit", $params); $sql = "SELECT COUNT(*) FROM ( @@ -1466,9 +1484,9 @@ function get_allowed_threshold_logs($sql_where = '', $order_by = 'td.name', $sql ) AS rower"; if (function_exists('get_total_row_data') && $graph_id == 0) { - $total_rows = get_total_row_data($user_id, $sql, [], 'thold_log', 10); + $total_rows = get_total_row_data($user_id, $sql, $params, 'thold_log', 10); } else { - $total_rows = db_fetch_cell($sql); + $total_rows = db_fetch_cell_prepared($sql, $params); } return $tholds; @@ -4065,7 +4083,7 @@ function thold_command_execution(&$thold_data, &$h, $breach_up, $breach_down, $b $queue = read_config_option('thold_notification_queue'); if ($breach_up && $thold_data['trigger_cmd_high'] != '') { - $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_high'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); + $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_high'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name, true); $cmd = thold_expand_string($thold_data, $cmd); @@ -4085,7 +4103,7 @@ function thold_command_execution(&$thold_data, &$h, $breach_up, $breach_down, $b $command_executed = true; } elseif ($breach_down && $thold_data['trigger_cmd_low'] != '') { - $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_low'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); + $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_low'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name, true); $cmd = thold_expand_string($thold_data, $cmd); $environment = thold_set_environ($thold_data['trigger_cmd_high'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); @@ -4104,7 +4122,7 @@ function thold_command_execution(&$thold_data, &$h, $breach_up, $breach_down, $b $command_executed = true; } elseif ($breach_norm && $thold_data['trigger_cmd_norm'] != '') { - $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_norm'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); + $cmd = thold_replace_threshold_tags($thold_data['trigger_cmd_norm'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name, true); $cmd = thold_expand_string($thold_data, $cmd); $environment = thold_set_environ($thold_data['trigger_cmd_high'], $thold_data, $h, $thold_data['lastread'], $thold_data['local_graph_id'], $data_source_name); @@ -4125,7 +4143,7 @@ function thold_command_execution(&$thold_data, &$h, $breach_up, $breach_down, $b } if ($queue == '' && $command_executed) { - thold_process_command_output($output, $return, 'thold', $thold_data, $cmd); + thold_process_command_output($output, $return, 'thold_cmd', $thold_data, $cmd); } } } @@ -4239,7 +4257,7 @@ function thold_set_environ($text, &$thold, &$h, $currentval, $local_graph_id, $d return $environment; } -function thold_replace_threshold_tags($text, &$thold, &$h, $currentval, $local_graph_id, $data_source_name) { +function thold_replace_threshold_tags($text, &$thold, &$h, $currentval, $local_graph_id, $data_source_name, $shell = false) { global $thold_types; if (substr(read_config_option('base_url'), 0, 4) != 'http') { @@ -4263,26 +4281,33 @@ function thold_replace_threshold_tags($text, &$thold, &$h, $currentval, $local_g $site = __('Default', 'thold'); } + // Device and threshold free-text values are admin/user editable. When $text + // is a trigger command template ($shell), quote them so they cannot + // terminate the command and start another. + $quote = function ($value) use ($shell) { + return $shell ? cacti_escapeshellarg((string) $value) : $value; + }; + // Do some replacement of variables - $text = thold_str_replace('', $h['description'], $text); - $text = thold_str_replace('', $h['hostname'], $text); - $text = thold_str_replace('', $h['location'], $text); - $text = thold_str_replace('', $site, $text); + $text = thold_str_replace('', $quote($h['description']), $text); + $text = thold_str_replace('', $quote($h['hostname']), $text); + $text = thold_str_replace('', $quote($h['location']), $text); + $text = thold_str_replace('', $quote($site), $text); $text = thold_str_replace('', $local_graph_id, $text); $text = thold_str_replace('', $thold['id'], $text); - $text = thold_str_replace('', $currentval, $text); - $text = thold_str_replace('', $thold['name_cache'], $text); + $text = thold_str_replace('', $quote($currentval), $text); + $text = thold_str_replace('', $quote($thold['name_cache']), $text); $text = thold_str_replace('', $data_source_name, $text); if (isset($thold_types[$thold['thold_type']])) { $text = thold_str_replace('', $thold_types[$thold['thold_type']], $text); } - $text = thold_str_replace('', $thold['notes'], $text); - $text = thold_str_replace('', $thold['dnotes'], $text); - $text = thold_str_replace('', $thold['dnotes'], $text); - $text = thold_str_replace('', $thold['external_id'], $text); + $text = thold_str_replace('', $quote($thold['notes']), $text); + $text = thold_str_replace('', $quote($thold['dnotes']), $text); + $text = thold_str_replace('', $quote($thold['dnotes']), $text); + $text = thold_str_replace('', $quote($thold['external_id']), $text); if ($thold['thold_type'] == 0) { $text = thold_str_replace('', $thold['thold_hi'], $text); From fae7826a3ea2ebb0de954429a40538f09dc9e5cd Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:03:52 -0400 Subject: [PATCH 03/18] fix(tests): stop includes/arrays.php loading from silently no-op'ing thold_functions.php includes includes/arrays.php with a plain include() (not include_once()) from several of its own functions (e.g. thold_log()), keyed off \['base_path']. Once any test in the shared Pest process exercised one of those call sites, PHP's include-once registry considered the file already included: a later thold_test_load() (require_once) on the same resolved path silently no-op'd and never (re)published \ to \, depending on which order the test files happened to run in. This intermittently broke every test that reads \ (TholdReplaceThresholdTagsTest's THOLDTYPE substitution) as well as tests several call frames downstream of thold_log()/thold_check_threshold() (NotificationEmailDeduplicationTest, ThresholdTimeBasedCharacterizationTest), depending on file execution order. Add thold_test_load_always()/loadPluginSourceAlways(), which use a plain require() instead of require_once(), for plugin files that only assign file-scope variables (no function/class declarations) and are therefore safe to load more than once. Switch the four test classes that load includes/arrays.php to the new helper. --- tests/TestCase.php | 19 +++++++++++ tests/Unit/TholdReplaceThresholdTagsTest.php | 2 +- .../ThresholdBaselineCharacterizationTest.php | 2 +- .../ThresholdHiLowCharacterizationTest.php | 2 +- ...ThresholdTimeBasedCharacterizationTest.php | 2 +- tests/bootstrap-unit.php | 32 +++++++++++++++++++ 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index a4b4151a..1020c2bb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -48,6 +48,25 @@ protected static function loadPluginSource($file) { thold_test_load(dirname(__DIR__) . '/' . $file); } + /** + * Load a plugin source file that only assigns file-scope variables (e.g. + * includes/arrays.php), bypassing the include-once registry. + * + * thold_functions.php includes includes/arrays.php with a plain include() + * (not include_once()) from several of its own functions. Once any test + * exercises one of those call sites, a later loadPluginSource() of the + * same file silently no-ops (require_once sees it as already included) + * and never (re)publishes $thold_types to $GLOBALS. Use this for any + * plugin file that only assigns variables, not functions/classes. + * + * @param string $file File name relative to the plugin root. + * + * @return void + */ + protected static function loadPluginSourceAlways($file) { + thold_test_load_always(dirname(__DIR__) . '/' . $file); + } + /** * Define the plugin's own constants by running the function that owns them. * diff --git a/tests/Unit/TholdReplaceThresholdTagsTest.php b/tests/Unit/TholdReplaceThresholdTagsTest.php index 96006c94..3bcc6ec7 100644 --- a/tests/Unit/TholdReplaceThresholdTagsTest.php +++ b/tests/Unit/TholdReplaceThresholdTagsTest.php @@ -30,7 +30,7 @@ public static function setUpBeforeClass(): void { self::loadPluginSource('thold_functions.php'); // Defines $thold_types, which the substitution reads. - self::loadPluginSource('includes/arrays.php'); + self::loadPluginSourceAlways('includes/arrays.php'); } /** diff --git a/tests/Unit/ThresholdBaselineCharacterizationTest.php b/tests/Unit/ThresholdBaselineCharacterizationTest.php index a510eaf0..932e3a9c 100644 --- a/tests/Unit/ThresholdBaselineCharacterizationTest.php +++ b/tests/Unit/ThresholdBaselineCharacterizationTest.php @@ -28,7 +28,7 @@ final class ThresholdBaselineCharacterizationTest extends TestCase { */ public static function setUpBeforeClass(): void { self::loadPluginSource('thold_functions.php'); - self::loadPluginSource('includes/arrays.php'); + self::loadPluginSourceAlways('includes/arrays.php'); self::loadPluginConstants(); } diff --git a/tests/Unit/ThresholdHiLowCharacterizationTest.php b/tests/Unit/ThresholdHiLowCharacterizationTest.php index d23613f5..a392d9ab 100644 --- a/tests/Unit/ThresholdHiLowCharacterizationTest.php +++ b/tests/Unit/ThresholdHiLowCharacterizationTest.php @@ -28,7 +28,7 @@ final class ThresholdHiLowCharacterizationTest extends TestCase { */ public static function setUpBeforeClass(): void { self::loadPluginSource('thold_functions.php'); - self::loadPluginSource('includes/arrays.php'); + self::loadPluginSourceAlways('includes/arrays.php'); self::loadPluginConstants(); } diff --git a/tests/Unit/ThresholdTimeBasedCharacterizationTest.php b/tests/Unit/ThresholdTimeBasedCharacterizationTest.php index e5a87fd5..9079c601 100644 --- a/tests/Unit/ThresholdTimeBasedCharacterizationTest.php +++ b/tests/Unit/ThresholdTimeBasedCharacterizationTest.php @@ -27,7 +27,7 @@ final class ThresholdTimeBasedCharacterizationTest extends TestCase { */ public static function setUpBeforeClass(): void { self::loadPluginSource('thold_functions.php'); - self::loadPluginSource('includes/arrays.php'); + self::loadPluginSourceAlways('includes/arrays.php'); self::loadPluginConstants(); } diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 6c4a8e4f..82d62775 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -566,3 +566,35 @@ function thold_test_load($path) { } } } + +/** + * Load a plugin source file that only assigns file-scope variables (no + * function/class declarations), publishing them to $GLOBALS the same way as + * thold_test_load(). + * + * includes/arrays.php is also included with a plain include() (not + * include_once()) from several places in thold_functions.php itself (e.g. + * thold_log()), keyed off $config['base_path']. Once any test exercises one + * of those call sites, PHP's include-once registry considers the file + * already included: a later thold_test_load() (require_once) on the same + * resolved path silently no-ops and never (re)publishes $thold_types. Using + * a plain require() here sidesteps that registry entirely, so re-running it + * is always safe and cheap for a file that just assigns arrays. + * + * @param string $path Absolute path to the file. + * + * @return void + */ +function thold_test_load_always($path) { + global $config; + + $__before = get_defined_vars(); + + require $path; + + foreach (get_defined_vars() as $__name => $__value) { + if (!array_key_exists($__name, $__before) && strncmp($__name, '__', 2) !== 0) { + $GLOBALS[$__name] = $__value; + } + } +} From 76d05ad300c8ef2375cfff99b8ec27c9630d0da6 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:32:26 -0400 Subject: [PATCH 04/18] debug: temporary CI diagnostics for notification/restoral test failures Adds stderr diagnostics behind THOLD_CI_DEBUG to see the real values computed on the Linux Pest CI run for the two failures that do not reproduce locally on Windows. Will be removed once the root cause is identified. --- thold_functions.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/thold_functions.php b/thold_functions.php index 00bdd3f9..d8cfb2d0 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,6 +3537,10 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); + if (getenv('THOLD_CI_DEBUG')) { + fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); + } + if ($alertstat != 0 && $warning_failures < $warning_trigger && $thold_data['restored_alert'] != 'on') { if (!$maint_dev) { if ($syslog) { @@ -7272,6 +7276,10 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; + if (getenv('THOLD_CI_DEBUG')) { + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); + } + if (!defined('TXT_SEP')) { define('TXT_SEP', '----------------------------------------------------------'); } @@ -7296,6 +7304,10 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); + if (getenv('THOLD_CI_DEBUG')) { + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . PHP_EOL); + } + if ($prev_suspended == 0) { foreach ($records as $index => $r) { $nstart = microtime(true); From 71877471e72710b6ba7a36c73906becc7c845d32 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:32:43 -0400 Subject: [PATCH 05/18] debug: make CI diagnostics fire unconditionally for this run --- thold_functions.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/thold_functions.php b/thold_functions.php index d8cfb2d0..e024663f 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,7 +3537,7 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); - if (getenv('THOLD_CI_DEBUG')) { + if (getenv('THOLD_CI_DEBUG') !== false || true) { fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); } @@ -7276,7 +7276,7 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; - if (getenv('THOLD_CI_DEBUG')) { + if (getenv('THOLD_CI_DEBUG') !== false || true) { fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); } @@ -7304,7 +7304,7 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); - if (getenv('THOLD_CI_DEBUG')) { + if (getenv('THOLD_CI_DEBUG') !== false || true) { fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . PHP_EOL); } From 26406221b1539988b48576cb636751415def0ec3 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:40:07 -0400 Subject: [PATCH 06/18] Revert "debug: make CI diagnostics fire unconditionally for this run" This reverts commit 71877471e72710b6ba7a36c73906becc7c845d32. --- thold_functions.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/thold_functions.php b/thold_functions.php index e024663f..d8cfb2d0 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,7 +3537,7 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); - if (getenv('THOLD_CI_DEBUG') !== false || true) { + if (getenv('THOLD_CI_DEBUG')) { fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); } @@ -7276,7 +7276,7 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; - if (getenv('THOLD_CI_DEBUG') !== false || true) { + if (getenv('THOLD_CI_DEBUG')) { fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); } @@ -7304,7 +7304,7 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); - if (getenv('THOLD_CI_DEBUG') !== false || true) { + if (getenv('THOLD_CI_DEBUG')) { fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . PHP_EOL); } From 525e4f852553d1e4de7d5eb85449137417e39553 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:40:07 -0400 Subject: [PATCH 07/18] Revert "debug: temporary CI diagnostics for notification/restoral test failures" This reverts commit 76d05ad300c8ef2375cfff99b8ec27c9630d0da6. --- thold_functions.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/thold_functions.php b/thold_functions.php index d8cfb2d0..00bdd3f9 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,10 +3537,6 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); - if (getenv('THOLD_CI_DEBUG')) { - fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); - } - if ($alertstat != 0 && $warning_failures < $warning_trigger && $thold_data['restored_alert'] != 'on') { if (!$maint_dev) { if ($syslog) { @@ -7276,10 +7272,6 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; - if (getenv('THOLD_CI_DEBUG')) { - fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); - } - if (!defined('TXT_SEP')) { define('TXT_SEP', '----------------------------------------------------------'); } @@ -7304,10 +7296,6 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); - if (getenv('THOLD_CI_DEBUG')) { - fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . PHP_EOL); - } - if ($prev_suspended == 0) { foreach ($records as $index => $r) { $nstart = microtime(true); From 7ce80b2d1ceab93c3d401214effda831180c4631 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:45:03 -0400 Subject: [PATCH 08/18] debug: temporary CI diagnostics (round 2) --- thold_functions.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/thold_functions.php b/thold_functions.php index 00bdd3f9..a9e98fe7 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,6 +3537,8 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); + fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); + if ($alertstat != 0 && $warning_failures < $warning_trigger && $thold_data['restored_alert'] != 'on') { if (!$maint_dev) { if ($syslog) { @@ -7272,6 +7274,8 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); + if (!defined('TXT_SEP')) { define('TXT_SEP', '----------------------------------------------------------'); } @@ -7296,6 +7300,8 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . ' prev_suspended=' . var_export($prev_suspended, true) . PHP_EOL); + if ($prev_suspended == 0) { foreach ($records as $index => $r) { $nstart = microtime(true); From 2edc92e4d9dbd4f2b59944d08d9c4feebaf63ce2 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:50:18 -0400 Subject: [PATCH 09/18] chore: retrigger CI From d8037ebd302eba554f63ce5cdb31f157496bb599 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:53:49 -0400 Subject: [PATCH 10/18] Revert "debug: temporary CI diagnostics (round 2)" This reverts commit 7ce80b2d1ceab93c3d401214effda831180c4631. --- thold_functions.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/thold_functions.php b/thold_functions.php index a9e98fe7..00bdd3f9 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,8 +3537,6 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); - fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); - if ($alertstat != 0 && $warning_failures < $warning_trigger && $thold_data['restored_alert'] != 'on') { if (!$maint_dev) { if ($syslog) { @@ -7274,8 +7272,6 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; - fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); - if (!defined('TXT_SEP')) { define('TXT_SEP', '----------------------------------------------------------'); } @@ -7300,8 +7296,6 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); - fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . ' prev_suspended=' . var_export($prev_suspended, true) . PHP_EOL); - if ($prev_suspended == 0) { foreach ($records as $index => $r) { $nstart = microtime(true); From 9aff7a428e395539dcf5726eda900a794c299529 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:54:33 -0400 Subject: [PATCH 11/18] debug: temporary CI diagnostics (round 3) --- thold_functions.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/thold_functions.php b/thold_functions.php index 00bdd3f9..a9e98fe7 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,6 +3537,8 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); + fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); + if ($alertstat != 0 && $warning_failures < $warning_trigger && $thold_data['restored_alert'] != 'on') { if (!$maint_dev) { if ($syslog) { @@ -7272,6 +7274,8 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); + if (!defined('TXT_SEP')) { define('TXT_SEP', '----------------------------------------------------------'); } @@ -7296,6 +7300,8 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . ' prev_suspended=' . var_export($prev_suspended, true) . PHP_EOL); + if ($prev_suspended == 0) { foreach ($records as $index => $r) { $nstart = microtime(true); From 7399a98749217806980a9e2702f3d95eaaa45063 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 22:59:45 -0400 Subject: [PATCH 12/18] chore: retrigger CI for diagnostics From 4bd7ad65967f79a02718cc88dc12876b8374fd87 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 23:01:32 -0400 Subject: [PATCH 13/18] Revert "debug: temporary CI diagnostics (round 3)" This reverts commit 9aff7a428e395539dcf5726eda900a794c299529. --- thold_functions.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/thold_functions.php b/thold_functions.php index a9e98fe7..00bdd3f9 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,8 +3537,6 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); - fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); - if ($alertstat != 0 && $warning_failures < $warning_trigger && $thold_data['restored_alert'] != 'on') { if (!$maint_dev) { if ($syslog) { @@ -7274,8 +7272,6 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; - fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); - if (!defined('TXT_SEP')) { define('TXT_SEP', '----------------------------------------------------------'); } @@ -7300,8 +7296,6 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); - fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . ' prev_suspended=' . var_export($prev_suspended, true) . PHP_EOL); - if ($prev_suspended == 0) { foreach ($records as $index => $r) { $nstart = microtime(true); From 4bb7f44ecf4b4efc19e1c2c6c5c6823fe9a5c32d Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 23:09:16 -0400 Subject: [PATCH 14/18] debug: temporary CI diagnostics (round 4) --- thold_functions.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/thold_functions.php b/thold_functions.php index 00bdd3f9..a9e98fe7 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,6 +3537,8 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); + fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); + if ($alertstat != 0 && $warning_failures < $warning_trigger && $thold_data['restored_alert'] != 'on') { if (!$maint_dev) { if ($syslog) { @@ -7272,6 +7274,8 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); + if (!defined('TXT_SEP')) { define('TXT_SEP', '----------------------------------------------------------'); } @@ -7296,6 +7300,8 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . ' prev_suspended=' . var_export($prev_suspended, true) . PHP_EOL); + if ($prev_suspended == 0) { foreach ($records as $index => $r) { $nstart = microtime(true); From e455b8dfc7ca236be6211e89e8819062a6fba66e Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 23:14:13 -0400 Subject: [PATCH 15/18] chore: retrigger CI (2) From 662c9f55e94d296ee5b5c30ec1390d1289773b9f Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sat, 19 Sep 2026 23:21:11 -0400 Subject: [PATCH 16/18] Revert "debug: temporary CI diagnostics (round 4)" This reverts commit 4bb7f44ecf4b4efc19e1c2c6c5c6823fe9a5c32d. --- thold_functions.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/thold_functions.php b/thold_functions.php index a9e98fe7..00bdd3f9 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,8 +3537,6 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); - fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); - if ($alertstat != 0 && $warning_failures < $warning_trigger && $thold_data['restored_alert'] != 'on') { if (!$maint_dev) { if ($syslog) { @@ -7274,8 +7272,6 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; - fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); - if (!defined('TXT_SEP')) { define('TXT_SEP', '----------------------------------------------------------'); } @@ -7300,8 +7296,6 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); - fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . ' prev_suspended=' . var_export($prev_suspended, true) . PHP_EOL); - if ($prev_suspended == 0) { foreach ($records as $index => $r) { $nstart = microtime(true); From 88a2eef37b912cdd50820f13f6162bab4be26826 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 20 Sep 2026 09:33:09 -0400 Subject: [PATCH 17/18] debug: temporary CI diagnostics (round 5) --- thold_functions.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/thold_functions.php b/thold_functions.php index 00bdd3f9..a9e98fe7 100644 --- a/thold_functions.php +++ b/thold_functions.php @@ -3537,6 +3537,8 @@ function thold_check_threshold(&$thold_data) { $subject = get_email_subject('NORMAL', false, $lastread, false, false, $thold_data); + fwrite(STDERR, 'THOLD_CI_DEBUG time-based restoral: alertstat=' . var_export($alertstat, true) . ' warning_failures=' . var_export($warning_failures, true) . ' warning_trigger=' . var_export($warning_trigger, true) . ' restored_alert=' . var_export($thold_data['restored_alert'], true) . PHP_EOL); + if ($alertstat != 0 && $warning_failures < $warning_trigger && $thold_data['restored_alert'] != 'on') { if (!$maint_dev) { if ($syslog) { @@ -7272,6 +7274,8 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { $one_email = read_config_option('alert_deadnotify_one_mail') == 'on' ? true : false; $emails = []; + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: one_email=' . var_export($one_email, true) . ' raw=' . var_export(read_config_option('alert_deadnotify_one_mail'), true) . PHP_EOL); + if (!defined('TXT_SEP')) { define('TXT_SEP', '----------------------------------------------------------'); } @@ -7296,6 +7300,8 @@ function process_device_notifications($pid, $max_records, $prev_suspended) { ORDER BY event_time ASC $sql_limit"); + fwrite(STDERR, 'THOLD_CI_DEBUG process_device_notifications: records=' . var_export($records, true) . ' prev_suspended=' . var_export($prev_suspended, true) . PHP_EOL); + if ($prev_suspended == 0) { foreach ($records as $index => $r) { $nstart = microtime(true); From 274ab221938e155886d6a83f9747332fd175f9d3 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 20 Sep 2026 09:40:12 -0400 Subject: [PATCH 18/18] chore: retrigger CI (3)