From eb77d251ebd93534b3ecc1529520afeac7817327 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 24 Jul 2026 08:46:21 -0400 Subject: [PATCH 01/36] code review sweep security: harden audit logging and management - prevent stored XSS in audit views - require CSRF-protected POST and management permission for purging - recursively redact web and CLI credentials - generate safe, standards-compliant CSV exports - record hook events explicitly as attempted actions - harden retention, replication, and external logging - add security regression checks - bump plugin version to 1.3 --- .github/workflows/plugin-ci-workflow.yml | 8 +- CHANGELOG.md | 6 + INFO | 2 +- README.md | 4 + audit.php | 391 ++++++++++++----------- audit_functions.php | 153 ++++++--- js/functions.js | 31 +- review.md | 318 ++++++++++++++++++ setup.php | 58 ++-- tests/controller_security_test.php | 32 ++ tests/security_functions_test.php | 52 +++ 11 files changed, 788 insertions(+), 267 deletions(-) create mode 100644 review.md create mode 100644 tests/controller_security_test.php create mode 100644 tests/security_functions_test.php diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 87991cc..5c85de3 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -180,6 +180,12 @@ jobs: echo "Syntax errors found!" exit 1 fi + + - name: Run Audit Security Helper Tests + run: | + cd ${{ github.workspace }}/cacti/plugins/audit + php tests/security_functions_test.php + php tests/controller_security_test.php - name: Run Cacti Poller @@ -221,5 +227,3 @@ jobs: echo "No audit log entries found!" exit 1 fi - - diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a51c0..e117d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ --- develop --- +* security: Escape stored audit data before rendering to prevent stored XSS +* security: Require an authorized, CSRF-protected POST to purge the audit log +* security: Recursively redact sensitive web and CLI values +* security: Generate standards-compliant, spreadsheet-safe CSV exports +* feature: Mark hook-time records explicitly as attempted actions +* issue: Harden external file logging, retention, malformed records, and replication * issue#38: Graph Template table does not exist * issue: If the audit log does not exist or is not set, set it and create it * issue: Audit assumes that all selected_items are numeric resulting in fatal error diff --git a/INFO b/INFO index 31298db..7fda835 100644 --- a/INFO +++ b/INFO @@ -21,7 +21,7 @@ [info] name = audit -version = 1.2 +version = 1.3 longname = Audit Plugin for Cacti author = The Cacti Group email = diff --git a/README.md b/README.md index 064d75d..95c2bfb 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ This plugin allows Cacti Administrators to log user change activity * Captures CLI commands and who ran them +Audit entries generated by the `config_insert` hook record requested actions as +`attempted`. They do not prove that the page-specific operation completed +successfully. + ## Installation Install just like any other plugin, just throw it in the plugin directory, and diff --git a/audit.php b/audit.php index dffed41..9f607ef 100644 --- a/audit.php +++ b/audit.php @@ -33,6 +33,17 @@ break; case 'purge': + if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + header('Allow: POST'); + exit; + } + + if (!api_plugin_user_realm_auth('audit_manage.php') || !csrf_check(false)) { + http_response_code(403); + exit; + } + audit_purge(); top_header(); @@ -40,101 +51,99 @@ bottom_footer(); break; - case 'getdata': - $data = db_fetch_row_prepared('SELECT * +case 'getdata': + $data = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', - array(get_filter_request_var('id'))); - - $output = ''; - - if ($data['action'] == 'cli') { - $width = 'wide'; - $output .= ''; - } - } else { - $output .= '
'; - $output .= '' . __('Page:', 'audit') . ' ' . $data['page'] . ''; - $output .= '
' . __('User:', 'audit') . ' ' . $data['user_agent'] . ''; - $output .= '
' . __('IP Address:', 'audit') . ' ' . $data['ip_address'] . ''; - $output .= '
' . __('Date:', 'audit') . ' ' . $data['event_time'] . ''; - $output .= '
' . __('Action:', 'audit') . ' ' . $data['action'] . ''; - $output .= '
'; - $output .= '' . __('Script:', 'audit') . ' ' . $data['post'] . ''; - } elseif (cacti_sizeof($data)) { - $attribs = json_decode($data['post']); - - $nattribs = array(); - foreach($attribs as $field => $content) { - $nattribs[$field] = $content; - } - ksort($nattribs); + array(get_filter_request_var('id'))); - if (cacti_sizeof($nattribs) > 16) { - $width = 'wide'; - } else { - $width = 'narrow'; - } + if (!cacti_sizeof($data)) { + http_response_code(404); + print html_escape(__('Audit event not found.', 'audit')); + break; + } - $output .= ''; - $output .= ''; +function audit_render_event_details($data) { + $width = 'wide'; + $output = '
'; - $output .= '' . __('Page:', 'audit') . ' ' . $data['page'] . ''; - $output .= '
' . __('User:', 'audit') . ' ' . get_username($data['user_id']) . ''; - $output .= '
' . __('IP Address:', 'audit') . ' ' . $data['ip_address'] . ''; - $output .= '
' . __('Date:', 'audit') . ' ' . $data['event_time'] . ''; - $output .= '
' . __('Action:', 'audit') . ' ' . $data['action'] . ''; - $output .= '
'; - $output .= ''; - - if (cacti_sizeof($nattribs) > 16) { - $columns = 2; - $output .= ''; - } else { - $columns = 1; - $output .= ''; - } + $output = audit_render_event_details($data); + echo $output; - $i = 0; - if (cacti_sizeof($nattribs)) { - foreach($nattribs as $field => $content) { - if ($i % $columns == 0) { - $output .= ($output != '' ? '':'') . ''; - } - - if (is_array($content)) { - $output .= '' . implode(',', $content) . ''; - } else { - $output .= ''; - } - - $i++; - } - - if ($i % $columns > 0) { - $output . ''; - } - } + break; +default: + top_header(); + audit_log(); + bottom_footer(); +} - // Display the Record Data under selected_items if it is not empty - $recordData = json_decode($data['object_data']); - if (!empty($recordData)) { - $output .= '
' . __('Attrib', 'audit') . '' . __('Value', 'audit') . '' . __('Attrib', 'audit') . '' . __('Value', 'audit') . '
' . __('Attrib', 'audit') . '' . __('Value', 'audit') . '
' . $field . '' . $field . '' . $content . '
'; - $output .= '

' . __('Record Data:', 'audit') . '
'; + $output .= '' . __('Page:', 'audit') . ' ' . html_escape($data['page']) . ''; + $output .= '
' . __('User:', 'audit') . ' ' . html_escape($data['action'] == 'cli' ? $data['user_agent'] : get_username($data['user_id'])) . ''; + $output .= '
' . __('IP Address:', 'audit') . ' ' . html_escape($data['ip_address']) . ''; + $output .= '
' . __('Date:', 'audit') . ' ' . html_escape($data['event_time']) . ''; + $output .= '
' . __('Action:', 'audit') . ' ' . html_escape($data['action']) . ''; + $output .= '
' . __('Outcome:', 'audit') . ' ' . html_escape($data['outcome']) . ''; + $output .= '
'; + + if ($data['action'] == 'cli') { + $output .= '' . __('Script:', 'audit') . ' ' . html_escape($data['post']) . ''; + return $output . '
'; + } - foreach ($recordData as $record) { - $output .= '
' . json_encode($record, JSON_PRETTY_PRINT) . '
'; - } + $attribs = json_decode($data['post'], true); + $attribs = is_array($attribs) ? $attribs : array( + __('Stored Data', 'audit') => __('The stored request data is not valid JSON.', 'audit') + ); + ksort($attribs); + + $columns = cacti_sizeof($attribs) > 16 ? 2 : 1; + $output .= ''; + $output .= $columns == 2 + ? '' + : ''; + + $i = 0; + foreach ($attribs as $field => $content) { + if ($i % $columns == 0) { + $output .= ''; } - // Output the final result - echo $output; + $output .= ''; + $i++; + + if ($i % $columns == 0) { + $output .= ''; + } + } + if ($i % $columns > 0) { + $output .= ''; + } - break; -default: - top_header(); - audit_log(); - bottom_footer(); + $record_data = json_decode($data['object_data'], true); + if (is_array($record_data) && !empty($record_data)) { + $output .= ''; + $output .= ''; + + foreach ($record_data as $record) { + $output .= ''; + } + } + + return $output . '
' . __('Attrib', 'audit') . '' . __('Value', 'audit') . '' . __('Attrib', 'audit') . '' . __('Value', 'audit') . '
' . __('Attrib', 'audit') . '' . __('Value', 'audit') . '
' . html_escape($field) . '' . audit_render_value($content) . '

' . __('Record Data:', 'audit') . '
' . html_escape(json_encode($record, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE)) . '
'; +} + +function audit_render_value($value) { + if (is_array($value) || is_object($value)) { + return '
' . html_escape(json_encode($value, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE)) . '
'; + } + + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } elseif ($value === null) { + $value = 'null'; + } + + return html_escape((string) $value); } function audit_purge() { @@ -148,67 +157,71 @@ function audit_purge() { } function audit_export_rows() { - process_request_vars(); + audit_process_request_vars(); /* form the 'where' clause for our main sql query */ + $sql_clauses = array(); + $sql_params = array(); + if (get_request_var('filter') != '') { - $sql_where = 'WHERE ( - page LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ' - OR post LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ')'; - } else { - $sql_where = ''; + $sql_clauses[] = '(page LIKE ? OR post LIKE ?)'; + $sql_params[] = '%' . get_request_var('filter') . '%'; + $sql_params[] = '%' . get_request_var('filter') . '%'; } if (get_request_var('event_page') != '-1') { - $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . ' page = ' . db_qstr(get_request_var('event_page')); + $sql_clauses[] = 'page = ?'; + $sql_params[] = get_request_var('event_page'); } if (!isempty_request_var('user_id') && get_request_var('user_id') > '-1') { - $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . ' user_id = ' . get_request_var('user_id'); + $sql_clauses[] = 'user_id = ?'; + $sql_params[] = get_request_var('user_id'); } - $events = db_fetch_assoc("SELECT audit_log.*, user_auth.username - FROM audit_log - LEFT JOIN user_auth - ON audit_log.user_id=user_auth.id - $sql_where"); + $sql_where = cacti_sizeof($sql_clauses) ? 'WHERE ' . implode(' AND ', $sql_clauses) : ''; + + $events = db_fetch_assoc_prepared("SELECT audit_log.*, user_auth.username + FROM audit_log + LEFT JOIN user_auth + ON audit_log.user_id=user_auth.id + $sql_where", + $sql_params); if (cacti_sizeof($events)) { header('Content-Disposition: attachment; filename=audit_export.csv'); + header('Content-Type: text/csv; charset=UTF-8'); + header('X-Content-Type-Options: nosniff'); - print __x('Column Header used for CSV log export. Ensure that you do NOT(!) remove one of the commas. The output needs to be CSV compliant.','page, user_id, username, action, ip_address, user_agent, event_time, post', 'audit') . "\n"; + $output = fopen('php://output', 'w'); + fputcsv($output, array('page', 'user_id', 'username', 'action', 'outcome', 'ip_address', 'user_agent', 'event_time', 'post')); foreach($events as $event) { - $post = json_decode($event['post']); - $poster = ''; - foreach($post as $var => $value) { - if (is_array($value)) { - $poster .= ($poster != '' ? '|':'') . $var . ':' . implode('%', $value); - } else { - $poster .= ($poster != '' ? '|':'') . $var . ':' . $value; - } + if ($event['action'] == 'cli') { + $poster = $event['post']; + } else { + $post = json_decode($event['post'], true); + $poster = is_array($post) ? json_encode($post, JSON_INVALID_UTF8_SUBSTITUTE) : $event['post']; } - print - $event['page'] . ', ' . - $event['user_id'] . ', ' . - get_username($event['user_id']) . ', ' . - $event['action'] . ', ' . - $event['ip_address'] . ', ' . - $event['user_agent'] . ', ' . - $event['event_time'] . ', ' . - $poster . "\n"; + fputcsv($output, array_map('audit_csv_safe_cell', array( + $event['page'], + $event['user_id'], + get_username($event['user_id']), + $event['action'], + $event['outcome'], + $event['ip_address'], + $event['user_agent'], + $event['event_time'], + $poster + ))); } - } -} -function audit_csv_escape($string) { - $string = str_replace('"', '', $string); - $string = str_replace(',', '|', $string); - return $string; + fclose($output); + } } -function process_request_vars() { +function audit_process_request_vars() { /* ================= input validation and session storage ================= */ $filters = array( 'rows' => array( @@ -255,7 +268,7 @@ function process_request_vars() { function audit_log() { global $item_rows; - process_request_vars(); + audit_process_request_vars(); if (get_request_var('rows') == '-1') { $rows = read_config_option('num_rows_table'); @@ -284,10 +297,10 @@ function audit_log() { + @@ -319,7 +333,7 @@ function audit_log() { $value) { - print "\n"; + print "\n"; } } ?> @@ -330,13 +344,14 @@ function audit_log() { - + + + - - - - '> + + + '> @@ -345,43 +360,50 @@ function audit_log() { html_end_box(); /* form the 'where' clause for our main sql query */ + $sql_clauses = array(); + $sql_params = array(); + if (get_request_var('filter') != '') { - $sql_where = 'WHERE ( - page LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ' - OR post LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ')'; - } else { - $sql_where = ''; + $sql_clauses[] = '(page LIKE ? OR post LIKE ?)'; + $sql_params[] = '%' . get_request_var('filter') . '%'; + $sql_params[] = '%' . get_request_var('filter') . '%'; } if (get_request_var('event_page') != '-1') { - $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . ' page = ' . db_qstr(get_request_var('event_page')); + $sql_clauses[] = 'page = ?'; + $sql_params[] = get_request_var('event_page'); } if (!isempty_request_var('user_id') && get_request_var('user_id') > '-1') { - $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . ' user_id = ' . get_request_var('user_id'); + $sql_clauses[] = 'user_id = ?'; + $sql_params[] = get_request_var('user_id'); } - $total_rows = db_fetch_cell("SELECT - COUNT(*) - FROM audit_log - LEFT JOIN user_auth - ON audit_log.user_id=user_auth.id - $sql_where"); + $sql_where = cacti_sizeof($sql_clauses) ? 'WHERE ' . implode(' AND ', $sql_clauses) : ''; + + $total_rows = db_fetch_cell_prepared("SELECT + COUNT(*) + FROM audit_log + LEFT JOIN user_auth + ON audit_log.user_id=user_auth.id + $sql_where", + $sql_params); $sql_order = get_order_string(); $sql_limit = ' LIMIT ' . ($rows*(get_request_var('page')-1)) . ',' . $rows; - $events = db_fetch_assoc("SELECT audit_log.*, user_auth.username - FROM audit_log - LEFT JOIN user_auth - ON audit_log.user_id=user_auth.id - $sql_where - $sql_order - $sql_limit"); + $events = db_fetch_assoc_prepared("SELECT audit_log.*, user_auth.username + FROM audit_log + LEFT JOIN user_auth + ON audit_log.user_id=user_auth.id + $sql_where + $sql_order + $sql_limit", + $sql_params); - $nav = html_nav_bar('audit.php?filter=' . get_request_var('filter'), MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, 5, __('Audit Events', 'audit'), 'page', 'main'); + $nav = html_nav_bar('audit.php?filter=' . rawurlencode(get_request_var('filter')), MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, 5, __('Audit Events', 'audit'), 'page', 'main'); - print $nav; + print $nav; html_start_box('', '100%', '', '3', 'center', ''); @@ -398,11 +420,17 @@ function audit_log() { 'sort' => 'ASC', 'tip' => __('The user who generated the event.', 'audit') ), - 'action' => array( - 'display' => __('Action', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('The Cacti Action requested. Hover over action to see $_POST data.', 'audit') + 'action' => array( + 'display' => __('Requested Action', 'audit'), + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('The requested Cacti action. Hover over the action to see request data.', 'audit') + ), + 'outcome' => array( + 'display' => __('Outcome', 'audit'), + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('Whether this event records an attempted or confirmed action.', 'audit') ), 'user_agent' => array( 'display' => __('User Agent', 'audit'), @@ -428,29 +456,31 @@ function audit_log() { $i = 0; if (cacti_sizeof($events)) { - foreach ($events as $e) { - if ($e['action'] == 'cli') { - form_alternate_row('line' . $e['id'], false); - form_selectable_cell($e['page'], $e['id']); - form_selectable_cell($e['user_agent'], $e['id']); - form_selectable_cell('' . ucfirst($e['action']) . '', $e['id']); - form_selectable_cell(__('N/A', 'audit'), $e['id']); - form_selectable_cell($e['ip_address'], $e['id'], '', 'right'); - form_selectable_cell($e['event_time'], $e['id'], '', 'right'); - form_end_row(); - } else { - form_alternate_row('line' . $e['id'], false); - form_selectable_cell(filter_value($e['page'], get_request_var('filter')), $e['id']); - form_selectable_cell($e['username'], $e['id']); - form_selectable_cell('' . ucfirst($e['action']) . '', $e['id']); - form_selectable_cell($e['user_agent'], $e['id']); - form_selectable_cell($e['ip_address'], $e['id'], '', 'right'); - form_selectable_cell($e['event_time'], $e['id'], '', 'right'); + foreach ($events as $e) { + if ($e['action'] == 'cli') { + form_alternate_row('line' . $e['id'], false); + form_selectable_ecell($e['page'], $e['id']); + form_selectable_ecell($e['user_agent'], $e['id']); + form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); + form_selectable_ecell($e['outcome'], $e['id']); + form_selectable_cell(__('N/A', 'audit'), $e['id']); + form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); + form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); + form_end_row(); + } else { + form_alternate_row('line' . $e['id'], false); + form_selectable_cell(filter_value($e['page'], get_request_var('filter')), $e['id']); + form_selectable_ecell($e['username'], $e['id']); + form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); + form_selectable_ecell($e['outcome'], $e['id']); + form_selectable_ecell($e['user_agent'], $e['id']); + form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); + form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); form_end_row(); } } } else { - print "" . __('No Audit Log Events Found', 'audit') . "\n"; + print "" . __('No Audit Log Events Found', 'audit') . "\n"; } html_end_box(false); @@ -463,4 +493,3 @@ function audit_log() { $value) { + if (audit_is_sensitive_key($key)) { + $redacted[$key] = '[REDACTED]'; + } elseif (is_array($value)) { + $redacted[$key] = audit_redact_sensitive_data($value); + } else { + $redacted[$key] = $value; + } + } + + return $redacted; +} + +function audit_redact_cli_arguments($arguments) { + $redacted = array(); + $redact_next = false; + + foreach ($arguments as $argument) { + if ($redact_next) { + $redacted[] = '[REDACTED]'; + $redact_next = false; + continue; + } + + if (preg_match('/^(--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)[^=]*)=(.*)$/i', $argument, $matches)) { + $redacted[] = $matches[1] . '=[REDACTED]'; + continue; + } + + if (preg_match('/^--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)/i', $argument)) { + $redacted[] = $argument; + $redact_next = true; + continue; + } + + $redacted[] = preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $argument); + } + + return $redacted; +} + +function audit_json_encode($data) { + $json = json_encode($data, JSON_INVALID_UTF8_SUBSTITUTE); + + if ($json === false) { + return json_encode(array('audit_encoding_error' => json_last_error_msg())); + } + + return $json; +} + +function audit_csv_safe_cell($value) { + $value = (string) $value; + + if (preg_match('/^[=+\-@]/', ltrim($value))) { + return "'" . $value; + } + + return $value; } @@ -128,16 +200,13 @@ function audit_config_insert() { if (audit_log_valid_event()) { /* prepare post */ - $post = $_REQUEST; + $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW); + $post = is_array($post) ? $post : array(); /* remove unsafe variables */ unset($post['__csrf_magic']); unset($post['header']); - foreach ($post as $key => $value) { - if (preg_match('/pass|phrase/i', $key)) { - unset($post[$key]); - } - } + $post = audit_redact_sensitive_data($post); /* check if drp_action is present and update action accordingly */ if (isset($post['drp_action']) && $post['drp_action'] == 1) { @@ -147,15 +216,16 @@ function audit_config_insert() { } /* sanitize and serialize selected items */ - if (isset($post['selected_items'])) { - $selected_items = unserialize(stripslashes($post['selected_items']), array('allowed_classes' => false)); - $drop_action = $post['drp_action']; + if (isset($post['selected_items']) && is_string($post['selected_items'])) { + $selected_items = @unserialize(stripslashes($post['selected_items']), array('allowed_classes' => false)); + $selected_items = is_array($selected_items) ? $selected_items : array(); + $drop_action = $post['drp_action'] ?? false; } else { $selected_items = array(); $drop_action = false; } - $post = json_encode($post); + $post = audit_json_encode($post); $page = basename($_SERVER['SCRIPT_NAME']); $user_id = (isset($_SESSION['sess_user_id']) ? $_SESSION['sess_user_id'] : 0); $event_time = date('Y-m-d H:i:s'); @@ -164,7 +234,7 @@ function audit_config_insert() { $ip_address = get_client_addr(); /* Get the User Agent */ - $user_agent = $_SERVER['HTTP_USER_AGENT']; + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; if (empty($action) && isset_request_var('action')) { $action = get_nfilter_request_var('action'); @@ -207,29 +277,36 @@ function audit_config_insert() { $base = CACTI_PATH_BASE; } - db_execute_prepared('INSERT INTO audit_log (page, user_id, action, ip_address, user_agent, event_time, post, object_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - array($page, $user_id, $action, $ip_address, $user_agent, $event_time, $post, $object_data)); + db_execute_prepared('INSERT INTO audit_log (page, user_id, action, outcome, ip_address, user_agent, event_time, post, object_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + array($page, $user_id, $action, 'attempted', $ip_address, $user_agent, $event_time, $post, $object_data)); + + $external_logging = read_config_option('audit_log_external') == 'on'; - if ($audit_log == '') { + if ($external_logging && $audit_log == '') { set_config_option('audit_log_external_path', $base . '/log/audit.log'); $audit_log = $base . '/log/audit.log'; } - if ($audit_log != '' && !file_exists($audit_log)) { + if ($external_logging && $audit_log != '' && !file_exists($audit_log)) { if (is_writable(dirname($audit_log))) { cacti_log(sprintf('NOTE: The Audit Log file \'%s\' does not exist. Creating it.', $audit_log), false, 'AUDIT'); - touch($audit_log); + if (!touch($audit_log)) { + cacti_log(sprintf('ERROR: Unable to create Audit Log file \'%s\'.', $audit_log), false, 'AUDIT'); + } else { + @chmod($audit_log, 0600); + } } else { cacti_log(sprintf('ERROR: Audit Log file path \'%s\' does not exist and the path is not writeable.', $audit_log), false, 'AUDIT'); } } - if (read_config_option('audit_log_external') == 'on' && $audit_log != '' && file_exists($audit_log)) { + if ($external_logging && $audit_log != '' && is_file($audit_log) && !is_link($audit_log)) { $log_data = array( 'page' => $page, 'user_id' => $user_id, 'action' => $action, + 'outcome' => 'attempted', 'ip_address' => $ip_address, 'user_agent' => $user_agent, 'event_time' => $event_time, @@ -237,33 +314,35 @@ function audit_config_insert() { 'object_data' => $object_data ); - $log_msg = json_encode($log_data) . "\n"; - $file = fopen($audit_log, 'a'); + $log_msg = audit_json_encode($log_data) . "\n"; + $written = file_put_contents($audit_log, $log_msg, FILE_APPEND | LOCK_EX); - if ($file) { - fwrite($file, $log_msg); - fclose($file); + if ($written !== strlen($log_msg)) { + cacti_log(sprintf('ERROR: Unable to append a complete record to Audit Log file \'%s\'.', $audit_log), false, 'AUDIT'); } + } elseif ($external_logging && $audit_log != '') { + cacti_log(sprintf('ERROR: Audit Log file \'%s\' is not a regular file or is a symbolic link.', $audit_log), false, 'AUDIT'); } - } elseif (isset($_SERVER['argv'])) { - $page = basename($_SERVER['argv'][0]); + } elseif (isset($_SERVER['argv']) && cacti_sizeof($_SERVER['argv'])) { + $arguments = audit_redact_cli_arguments($_SERVER['argv']); + $page = basename($arguments[0]); $user_id = 0; $action = 'cli'; $ip_address = getHostByName(php_uname('n')); $user_agent = get_current_user(); $event_time = date('Y-m-d H:i:s'); - $post = implode(' ', $_SERVER['argv']); + $post = implode(' ', $arguments); /* don't insert poller records */ - if (strpos($_SERVER['argv'][0], 'poller') === false && - strpos($_SERVER['argv'][0], 'cmd.php') === false && - strpos($_SERVER['argv'][0], '/scripts/') === false && - strpos($_SERVER['argv'][0], 'script_server.php') === false && - strpos($_SERVER['argv'][0], '_process.php') === false) { - - db_execute_prepared('INSERT INTO audit_log (page, user_id, action, ip_address, user_agent, event_time, post) - VALUES (?, ?, ?, ?, ?, ?, ?)', - array($page, $user_id, $action, $ip_address, $user_agent, $event_time, $post)); + if (strpos($arguments[0], 'poller') === false && + strpos($arguments[0], 'cmd.php') === false && + strpos($arguments[0], '/scripts/') === false && + strpos($arguments[0], 'script_server.php') === false && + strpos($arguments[0], '_process.php') === false) { + + db_execute_prepared('INSERT INTO audit_log (page, user_id, action, outcome, ip_address, user_agent, event_time, post) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + array($page, $user_id, $action, 'attempted', $ip_address, $user_agent, $event_time, $post)); } } -} \ No newline at end of file +} diff --git a/js/functions.js b/js/functions.js index 693b7c3..41bf752 100644 --- a/js/functions.js +++ b/js/functions.js @@ -31,12 +31,12 @@ * Apply filter to audit log */ function audit_applyFilter() { - strURL = 'audit.php' + - '?filter='+$('#filter').val()+ - '&rows='+$('#rows').val()+ - '&page='+$('#page').val()+ - '&event_page='+$('#event_page').val()+ - '&user_id='+$('#user_id').val()+ + var strURL = 'audit.php' + + '?filter='+encodeURIComponent($('#filter').val())+ + '&rows='+encodeURIComponent($('#rows').val())+ + '&page='+encodeURIComponent($('#page').val())+ + '&event_page='+encodeURIComponent($('#event_page').val())+ + '&user_id='+encodeURIComponent($('#user_id').val())+ '&header=false'; loadPageNoHeader(strURL); } @@ -45,7 +45,7 @@ function audit_applyFilter() { * Clear all filters */ function audit_clearFilter() { - strURL = 'audit.php?clear=1&header=false'; + var strURL = 'audit.php?clear=1&header=false'; loadPageNoHeader(strURL); } @@ -108,15 +108,20 @@ $(function() { }); $('#purge').click(function() { - strURL = 'audit.php?action=purge&header=false'; - loadPageNoHeader(strURL); + if (!window.confirm($(this).data('confirm'))) { + return; + } + + loadPageUsingPost('audit.php?action=purge&header=false', { + __csrf_magic: csrfMagicToken + }); }); $('#export').click(function() { document.location = 'audit.php?action=export' + - '&filter='+$('#filter').val()+ - '&event_page='+$('#event_page').val()+ - '&user_id='+$('#user_id').val(); + '&filter='+encodeURIComponent($('#filter').val())+ + '&event_page='+encodeURIComponent($('#event_page').val())+ + '&user_id='+encodeURIComponent($('#user_id').val()); }); $('#form_audit').submit(function(event) { @@ -128,7 +133,7 @@ $(function() { $('span[id^="event"]').hover(function() { audit_close_dialog(); - id = $(this).attr('id').replace('event', ''); + var id = $(this).attr('id').replace('event', ''); if (auditTimer != null) { clearTimeout(auditTimer); diff --git a/review.md b/review.md new file mode 100644 index 0000000..d64aec5 --- /dev/null +++ b/review.md @@ -0,0 +1,318 @@ +# Audit Plugin Code Review + +> Remediation update (branch `code_audit`): the implementation now addresses +> AUD-001 through AUD-010, adds focused security-helper coverage for AUD-011, +> and bumps the plugin schema/version to 1.3. The findings below describe the +> pre-remediation code that was reviewed and are retained as the audit record. +> Full browser/database integration coverage is still recommended before release. + +## Executive summary + +The plugin is small and readable, uses prepared statements for its primary insert and object lookups, and passes PHP syntax checks. However, it should not be released in its current form. The audit found two high-severity security defects, several confidentiality and audit-integrity weaknesses, broken remote-replication code, and no meaningful automated coverage for the sensitive paths. + +The highest priorities are: + +1. Escape every value rendered from `audit_log`. +2. Make purge a CSRF-protected POST operation with explicit authorization. +3. Replace the current credential redaction with recursive, allowlist-oriented collection, including CLI arguments. +4. Produce exports with a real CSV writer and neutralize spreadsheet formulas. +5. Define whether records represent attempts or successful changes and record that outcome accurately. + +## Scope and methodology + +Reviewed: + +- `.github/copilot-instructions.md` +- `setup.php` +- `audit.php` +- `audit_functions.php` +- `js/functions.js` +- `INFO`, `README.md`, `CHANGELOG.md` +- `.github/workflows/plugin-ci-workflow.yml` +- localization build assets and repository layout + +The review traced install/upgrade/uninstall, hook registration, GUI and CLI event capture, database writes and reads, record-detail rendering, filtering, export, purge, retention, external-file logging, and remote replication. Cacti core helpers in the adjacent Cacti checkout were consulted to verify authorization, CSRF, sorting, validation, and HTML helper behavior. + +Validation performed: + +```text +find . -name '*.php' -print0 | xargs -0 -n1 php -l +``` + +Result: every PHP file passed syntax validation. + +No plugin unit or functional test suite is present. The CI workflow installs the plugin, checks syntax, runs the poller, and asserts that at least one CLI audit row exists; it does not exercise the web UI or any negative/security cases. + +## Findings + +### AUD-001 — High — Stored XSS in audit detail and list views + +**Evidence** + +- `audit.php:54-60` directly concatenates CLI record fields into HTML. +- `audit.php:77-81` directly concatenates page, username, IP address, date, and action into HTML. +- `audit.php:95-104` directly renders request field names and values. +- `audit.php:121-123` embeds JSON record data inside `
` without HTML escaping.
+- `audit.php:432-448` passes database values such as `user_agent`, username, page, IP, and action to `form_selectable_cell()`. Cacti's `form_selectable_cell()` does not escape its content; `form_selectable_ecell()` is the escaping variant.
+- `audit_functions.php:131-158` stores attacker-influenced request data, and `audit_functions.php:167` stores the request's `User-Agent`.
+
+**Impact**
+
+An authenticated user who can cause an audited request can persist HTML or JavaScript in a request value or user-agent string. When an administrator with the audit realm views the list or hovers over the event, that content is inserted into the DOM. This can execute script in the administrator's Cacti session and may permit administrative account compromise.
+
+**Recommendation**
+
+- Escape all scalar values at the final HTML output boundary with `html_escape()`/`htmlspecialchars(..., ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')`.
+- Recursively render arrays and objects as escaped text, never as markup.
+- Use `form_selectable_ecell()` for plain database values. Keep `form_selectable_cell()` only where the plugin itself constructs trusted markup, and escape the interpolated action inside that markup.
+- Return detail data as JSON and construct text nodes client-side, or keep the HTML endpoint but apply centralized contextual escaping.
+- Add regression tests containing `'
+	)
+);
+
+$redacted = audit_redact_sensitive_data($request);
+
+audit_test_assert_same('[REDACTED]', $redacted['password'], 'Top-level passwords must be redacted.');
+audit_test_assert_same('[REDACTED]', $redacted['nested']['api_token'], 'Nested tokens must be redacted.');
+audit_test_assert_same(
+	'',
+	$redacted['nested']['description'],
+	'Non-secret data must remain available for later context-aware output escaping.'
+);
+
+$arguments = audit_redact_cli_arguments(array(
+	'cli/example.php',
+	'--password=secret-one',
+	'--api-token',
+	'secret-two',
+	'https://user:secret-three@example.com/path',
+	'--description=test'
+));
+
+audit_test_assert_same('--password=[REDACTED]', $arguments[1], 'Inline CLI passwords must be redacted.');
+audit_test_assert_same('[REDACTED]', $arguments[3], 'Separate CLI secret values must be redacted.');
+audit_test_assert_same(
+	'https://user:[REDACTED]@example.com/path',
+	$arguments[4],
+	'Credentials embedded in a URI must be redacted.'
+);
+
+audit_test_assert_same("'=1+1", audit_csv_safe_cell('=1+1'), 'Spreadsheet formulas must be neutralized.');
+
+print "Security helper tests passed.\n";

From 1dc34b24080cf10f4d6a5103c8fd7a1cb5b25f22 Mon Sep 17 00:00:00 2001
From: Sean Mancini 
Date: Fri, 24 Jul 2026 08:50:21 -0400
Subject: [PATCH 02/36] Update audit.php

---
 audit.php | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/audit.php b/audit.php
index 9f607ef..1c39858 100644
--- a/audit.php
+++ b/audit.php
@@ -194,7 +194,7 @@ function audit_export_rows() {
 		header('X-Content-Type-Options: nosniff');
 
 		$output = fopen('php://output', 'w');
-		fputcsv($output, array('page', 'user_id', 'username', 'action', 'outcome', 'ip_address', 'user_agent', 'event_time', 'post'));
+		fputcsv($output, array('page', 'user_id', 'username', 'action', 'outcome', 'ip_address', 'user_agent', 'event_time', 'post'), ',', '"', '');
 
 		foreach($events as $event) {
 			if ($event['action'] == 'cli') {
@@ -214,7 +214,7 @@ function audit_export_rows() {
 				$event['user_agent'],
 				$event['event_time'],
 				$poster
-			)));
+			)), ',', '"', '');
 		}
 
 		fclose($output);

From 98c88f7b4f3d5768dc773ea3dc20e5fd44d6746d Mon Sep 17 00:00:00 2001
From: Sean Mancini 
Date: Fri, 24 Jul 2026 09:03:49 -0400
Subject: [PATCH 03/36] additional sweep

security: complete audit logging hardening

- bound request depth, field counts, string sizes, and JSON parsing
- detect and redact additional secret-shaped values
- finalize request outcomes as completed or failed
- track and retry failed external log delivery
- expose delivery status in the UI and CSV exports
- upgrade existing remote-poller schemas
- add retention, schema, delivery, and security regression tests
- update documentation and translation template
- bump plugin version to 1.4
---
 .github/workflows/plugin-ci-workflow.yml |  20 +++
 CHANGELOG.md                             |   3 +
 INFO                                     |   2 +-
 README.md                                |  11 +-
 audit.php                                |  28 +++-
 audit_functions.php                      | 178 +++++++++++++++++++++--
 locales/po/cacti.pot                     |  53 +++++++
 review.md                                |   7 +-
 setup.php                                |  16 +-
 tests/controller_security_test.php       |  18 +++
 tests/security_functions_test.php        |  50 ++++++-
 11 files changed, 357 insertions(+), 29 deletions(-)

diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml
index 5c85de3..8e6131a 100644
--- a/.github/workflows/plugin-ci-workflow.yml
+++ b/.github/workflows/plugin-ci-workflow.yml
@@ -172,6 +172,20 @@ jobs:
       run: |
         cd ${{ github.workspace }}/cacti
         sudo php cli/plugin_manage.php --plugin=audit --install --enable
+
+    - name: Verify Audit Schema
+      run: |
+        COLUMN_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se "
+          SELECT COUNT(*)
+          FROM information_schema.columns
+          WHERE table_schema = 'cacti'
+            AND table_name = 'audit_log'
+            AND column_name IN ('outcome', 'external_status', 'external_error');
+        ")
+        if [ "$COLUMN_COUNT" -ne 3 ]; then
+          echo "Audit schema security columns are missing"
+          exit 1
+        fi
     
     - name: Check PHP Syntax for Plugin
       run: |
@@ -227,3 +241,9 @@ jobs:
           echo "No audit log entries found!"
           exit 1
         fi
+
+        CLI_OUTCOME=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se "select outcome from audit_log where action = 'cli' order by id desc limit 1;")
+        if [ "$CLI_OUTCOME" != "attempted" ]; then
+          echo "Unexpected CLI audit outcome: $CLI_OUTCOME"
+          exit 1
+        fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e117d79..0fd3986 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,14 @@
 
 --- develop ---
 
+* feature: Track and retry failed external audit-log delivery
+* security: Bound nested request depth, field counts, string sizes, and JSON parsing
 * security: Escape stored audit data before rendering to prevent stored XSS
 * security: Require an authorized, CSRF-protected POST to purge the audit log
 * security: Recursively redact sensitive web and CLI values
 * security: Generate standards-compliant, spreadsheet-safe CSV exports
 * feature: Mark hook-time records explicitly as attempted actions
+* feature: Finalize request outcomes and expose external-log delivery status
 * issue: Harden external file logging, retention, malformed records, and replication
 * issue#38: Graph Template table does not exist
 * issue: If the audit log does not exist or is not set, set it and create it
diff --git a/INFO b/INFO
index 7fda835..6510278 100644
--- a/INFO
+++ b/INFO
@@ -21,7 +21,7 @@
 
 [info]
 name = audit
-version = 1.3
+version = 1.4
 longname = Audit Plugin for Cacti
 author = The Cacti Group
 email = 
diff --git a/README.md b/README.md
index 95c2bfb..f085034 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,10 @@ This plugin allows Cacti Administrators to log user change activity
 
 * Captures CLI commands and who ran them
 
-Audit entries generated by the `config_insert` hook record requested actions as
-`attempted`. They do not prove that the page-specific operation completed
-successfully.
+Audit entries generated by the `config_insert` hook begin as `attempted` and are
+finalized as `request_completed` or `request_failed` when PHP shuts down. A
+completed request does not necessarily prove that every page-specific database
+operation succeeded.
 
 ## Installation
 
@@ -30,6 +31,10 @@ data retention and turn on auditing.
 
 You can also enable file based logging for ingestion by Siem or Log analysis tools such as splunk
 
+External file delivery is tracked on each database record. Failed appends are
+retried by the poller in batches and therefore have at-least-once delivery
+semantics; downstream ingestion should deduplicate when necessary.
+
 ## Possible Bugs
 
 If you figure out this problem, see the Cacti forums!
diff --git a/audit.php b/audit.php
index 1c39858..82d9be7 100644
--- a/audit.php
+++ b/audit.php
@@ -82,6 +82,10 @@ function audit_render_event_details($data) {
 	$output .= '
' . __('Date:', 'audit') . ' ' . html_escape($data['event_time']) . ''; $output .= '
' . __('Action:', 'audit') . ' ' . html_escape($data['action']) . ''; $output .= '
' . __('Outcome:', 'audit') . ' ' . html_escape($data['outcome']) . ''; + $output .= '
' . __('External Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . ''; + if ($data['external_error'] != '') { + $output .= '
' . __('External Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; + } $output .= '
'; if ($data['action'] == 'cli') { @@ -89,9 +93,9 @@ function audit_render_event_details($data) { return $output . ''; } - $attribs = json_decode($data['post'], true); + $attribs = audit_json_decode($data['post'], $json_error); $attribs = is_array($attribs) ? $attribs : array( - __('Stored Data', 'audit') => __('The stored request data is not valid JSON.', 'audit') + __('Stored Data', 'audit') => __('The stored request data is not valid JSON: %s', $json_error, 'audit') ); ksort($attribs); @@ -119,7 +123,7 @@ function audit_render_event_details($data) { $output .= ''; } - $record_data = json_decode($data['object_data'], true); + $record_data = audit_json_decode($data['object_data'], $object_error); if (is_array($record_data) && !empty($record_data)) { $output .= '
'; $output .= '' . __('Record Data:', 'audit') . ''; @@ -194,13 +198,13 @@ function audit_export_rows() { header('X-Content-Type-Options: nosniff'); $output = fopen('php://output', 'w'); - fputcsv($output, array('page', 'user_id', 'username', 'action', 'outcome', 'ip_address', 'user_agent', 'event_time', 'post'), ',', '"', ''); + fputcsv($output, array('page', 'user_id', 'username', 'action', 'outcome', 'external_status', 'external_error', 'ip_address', 'user_agent', 'event_time', 'post'), ',', '"', ''); foreach($events as $event) { if ($event['action'] == 'cli') { $poster = $event['post']; } else { - $post = json_decode($event['post'], true); + $post = audit_json_decode($event['post'], $json_error); $poster = is_array($post) ? json_encode($post, JSON_INVALID_UTF8_SUBSTITUTE) : $event['post']; } @@ -210,6 +214,8 @@ function audit_export_rows() { get_username($event['user_id']), $event['action'], $event['outcome'], + $event['external_status'], + $event['external_error'], $event['ip_address'], $event['user_agent'], $event['event_time'], @@ -430,7 +436,13 @@ function audit_log() { 'display' => __('Outcome', 'audit'), 'align' => 'left', 'sort' => 'ASC', - 'tip' => __('Whether this event records an attempted or confirmed action.', 'audit') + 'tip' => __('Request processing state; completion does not guarantee that every operation succeeded.', 'audit') + ), + 'external_status' => array( + 'display' => __('External Delivery', 'audit'), + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('Delivery state for the optional external audit log.', 'audit') ), 'user_agent' => array( 'display' => __('User Agent', 'audit'), @@ -463,6 +475,7 @@ function audit_log() { form_selectable_ecell($e['user_agent'], $e['id']); form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); form_selectable_ecell($e['outcome'], $e['id']); + form_selectable_ecell($e['external_status'], $e['id']); form_selectable_cell(__('N/A', 'audit'), $e['id']); form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); @@ -473,6 +486,7 @@ function audit_log() { form_selectable_ecell($e['username'], $e['id']); form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); form_selectable_ecell($e['outcome'], $e['id']); + form_selectable_ecell($e['external_status'], $e['id']); form_selectable_ecell($e['user_agent'], $e['id']); form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); @@ -480,7 +494,7 @@ function audit_log() { } } } else { - print "" . __('No Audit Log Events Found', 'audit') . "\n"; + print "" . __('No Audit Log Events Found', 'audit') . "\n"; } html_end_box(false); diff --git a/audit_functions.php b/audit_functions.php index 7b5b1dc..89c9848 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -138,13 +138,59 @@ function audit_redact_sensitive_data($data) { } elseif (is_array($value)) { $redacted[$key] = audit_redact_sensitive_data($value); } else { - $redacted[$key] = $value; + $redacted[$key] = audit_redact_sensitive_value($value); } } return $redacted; } +function audit_redact_sensitive_value($value) { + if (!is_string($value)) { + return $value; + } + + if (preg_match('/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/', $value) || + preg_match('/^(?:Bearer|Basic)\s+[A-Za-z0-9+\/_=.-]+$/i', trim($value)) || + preg_match('/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/', trim($value))) { + return '[REDACTED]'; + } + + return preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $value); +} + +function audit_bound_log_data($data, $depth = 0, $state = null) { + if ($state === null) { + $state = (object) array('fields' => 0); + } + + if ($depth >= 12) { + return '[MAXIMUM DEPTH REACHED]'; + } + + if (is_array($data)) { + $bounded = array(); + + foreach ($data as $key => $value) { + if ($state->fields >= 1000) { + $bounded['audit_truncated'] = 'Additional fields were omitted.'; + break; + } + + $state->fields++; + $bounded[$key] = audit_bound_log_data($value, $depth + 1, $state); + } + + return $bounded; + } + + if (is_string($data) && strlen($data) > 65536) { + return substr($data, 0, 65536) . '[TRUNCATED]'; + } + + return $data; +} + function audit_redact_cli_arguments($arguments) { $redacted = array(); $redact_next = false; @@ -174,7 +220,7 @@ function audit_redact_cli_arguments($arguments) { } function audit_json_encode($data) { - $json = json_encode($data, JSON_INVALID_UTF8_SUBSTITUTE); + $json = json_encode(audit_bound_log_data($data), JSON_INVALID_UTF8_SUBSTITUTE, 16); if ($json === false) { return json_encode(array('audit_encoding_error' => json_last_error_msg())); @@ -183,6 +229,17 @@ function audit_json_encode($data) { return $json; } +function audit_json_decode($json, &$error = null) { + $error = null; + + try { + return json_decode($json, true, 16, JSON_THROW_ON_ERROR); + } catch (Throwable $exception) { + $error = $exception->getMessage(); + return null; + } +} + function audit_csv_safe_cell($value) { $value = (string) $value; @@ -193,6 +250,96 @@ function audit_csv_safe_cell($value) { return $value; } +function audit_retention_cutoff($retention, $now = null) { + $now = $now instanceof DateTimeImmutable + ? $now->setTimezone(new DateTimeZone('UTC')) + : new DateTimeImmutable('now', new DateTimeZone('UTC')); + + return $now->sub(new DateInterval('P' . max(0, (int) $retention) . 'D')); +} + +function audit_append_external_log($path, $message) { + if ($path == '' || !is_file($path) || is_link($path)) { + return array('status' => 'failed', 'error' => 'Destination is not a regular file or is a symbolic link.'); + } + + $written = file_put_contents($path, $message, FILE_APPEND | LOCK_EX); + if ($written !== strlen($message)) { + return array('status' => 'failed', 'error' => 'Unable to append a complete record.'); + } + + return array('status' => 'delivered', 'error' => ''); +} + +function audit_set_external_status($id, $status, $error = '') { + db_execute_prepared('UPDATE audit_log + SET external_status = ?, external_error = ? + WHERE id = ?', + array($status, $error, $id)); +} + +function audit_retry_external_logs() { + if (read_config_option('audit_log_external') != 'on') { + return; + } + + $path = read_config_option('audit_log_external_path'); + if ($path == '' || !is_file($path) || is_link($path)) { + return; + } + + $events = db_fetch_assoc("SELECT * + FROM audit_log + WHERE external_status = 'failed' + ORDER BY id + LIMIT 100"); + + foreach ($events as $event) { + $log_data = array( + 'page' => $event['page'], + 'user_id' => $event['user_id'], + 'action' => $event['action'], + 'outcome' => $event['outcome'], + 'ip_address' => $event['ip_address'], + 'user_agent' => $event['user_agent'], + 'event_time' => $event['event_time'], + 'post' => $event['post'], + 'object_data' => $event['object_data'] + ); + + $message = audit_json_encode($log_data) . "\n"; + $delivery = audit_append_external_log($path, $message); + audit_set_external_status($event['id'], $delivery['status'], $delivery['error']); + + if ($delivery['status'] != 'delivered') { + break; + } + } +} + +function audit_request_outcome($error = null, $status_code = 200) { + $fatal_types = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR); + + if ((is_array($error) && in_array($error['type'] ?? null, $fatal_types, true)) || + $status_code >= 400) { + return 'request_failed'; + } + + return 'request_completed'; +} + +function audit_finalize_request($id) { + $status_code = http_response_code(); + $status_code = is_int($status_code) ? $status_code : 200; + $outcome = audit_request_outcome(error_get_last(), $status_code); + + db_execute_prepared("UPDATE audit_log + SET outcome = ? + WHERE id = ? + AND outcome = 'attempted'", + array($outcome, $id)); +} + function audit_config_insert() { @@ -270,6 +417,8 @@ function audit_config_insert() { } $audit_log = read_config_option('audit_log_external_path'); + $external_logging = read_config_option('audit_log_external') == 'on'; + $external_status = $external_logging ? 'pending' : 'disabled'; if (!defined('CACTI_PATH_BASE')) { $base = $config['base_path']; @@ -277,11 +426,11 @@ function audit_config_insert() { $base = CACTI_PATH_BASE; } - db_execute_prepared('INSERT INTO audit_log (page, user_id, action, outcome, ip_address, user_agent, event_time, post, object_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - array($page, $user_id, $action, 'attempted', $ip_address, $user_agent, $event_time, $post, $object_data)); - - $external_logging = read_config_option('audit_log_external') == 'on'; + db_execute_prepared('INSERT INTO audit_log (page, user_id, action, outcome, ip_address, user_agent, event_time, post, object_data, external_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + array($page, $user_id, $action, 'attempted', $ip_address, $user_agent, $event_time, $post, $object_data, $external_status)); + $audit_id = db_fetch_insert_id(); + register_shutdown_function('audit_finalize_request', $audit_id); if ($external_logging && $audit_log == '') { set_config_option('audit_log_external_path', $base . '/log/audit.log'); @@ -315,12 +464,15 @@ function audit_config_insert() { ); $log_msg = audit_json_encode($log_data) . "\n"; - $written = file_put_contents($audit_log, $log_msg, FILE_APPEND | LOCK_EX); + $delivery = audit_append_external_log($audit_log, $log_msg); + audit_set_external_status($audit_id, $delivery['status'], $delivery['error']); - if ($written !== strlen($log_msg)) { - cacti_log(sprintf('ERROR: Unable to append a complete record to Audit Log file \'%s\'.', $audit_log), false, 'AUDIT'); + if ($delivery['status'] != 'delivered') { + cacti_log(sprintf('ERROR: Unable to append a complete record to Audit Log file \'%s\': %s', $audit_log, $delivery['error']), false, 'AUDIT'); } } elseif ($external_logging && $audit_log != '') { + $error = 'Destination is not a regular file or is a symbolic link.'; + audit_set_external_status($audit_id, 'failed', $error); cacti_log(sprintf('ERROR: Audit Log file \'%s\' is not a regular file or is a symbolic link.', $audit_log), false, 'AUDIT'); } } elseif (isset($_SERVER['argv']) && cacti_sizeof($_SERVER['argv'])) { @@ -340,9 +492,9 @@ function audit_config_insert() { strpos($arguments[0], 'script_server.php') === false && strpos($arguments[0], '_process.php') === false) { - db_execute_prepared('INSERT INTO audit_log (page, user_id, action, outcome, ip_address, user_agent, event_time, post) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - array($page, $user_id, $action, 'attempted', $ip_address, $user_agent, $event_time, $post)); + db_execute_prepared('INSERT INTO audit_log (page, user_id, action, outcome, ip_address, user_agent, event_time, post, external_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + array($page, $user_id, $action, 'attempted', $ip_address, $user_agent, $event_time, $post, 'not_applicable')); } } } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 556ffbc..0d57f0c 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -283,3 +283,56 @@ msgstr "" #: setup.php:316 msgid "Audit Event Log" msgstr "" + +#: audit.php +msgid "Audit event not found." +msgstr "" + +#: audit.php +msgid "External Delivery:" +msgstr "" + +#: audit.php +msgid "External Error:" +msgstr "" + +#: audit.php +msgid "Stored Data" +msgstr "" + +#: audit.php +#, php-format +msgid "The stored request data is not valid JSON: %s" +msgstr "" + +#: audit.php +msgid "Requested Action" +msgstr "" + +#: audit.php +msgid "The requested Cacti action. Hover over the action to see request data." +msgstr "" + +#: audit.php +msgid "Outcome" +msgstr "" + +#: audit.php +msgid "Request processing state; completion does not guarantee that every operation succeeded." +msgstr "" + +#: audit.php +msgid "External Delivery" +msgstr "" + +#: audit.php +msgid "Delivery state for the optional external audit log." +msgstr "" + +#: audit.php +msgid "Permanently purge all audit log events?" +msgstr "" + +#: setup.php +msgid "Manage Cacti Audit Log" +msgstr "" diff --git a/review.md b/review.md index d64aec5..f35b4cf 100644 --- a/review.md +++ b/review.md @@ -2,9 +2,14 @@ > Remediation update (branch `code_audit`): the implementation now addresses > AUD-001 through AUD-010, adds focused security-helper coverage for AUD-011, -> and bumps the plugin schema/version to 1.3. The findings below describe the +> and bumps the plugin schema/version to 1.4. The findings below describe the > pre-remediation code that was reviewed and are retained as the audit record. > Full browser/database integration coverage is still recommended before release. +> Generic requests are finalized as completed or failed, but operation-specific +> success cannot be proven without corresponding completion hooks in each Cacti +> page. Request collection uses recursive redaction and strict size/depth bounds; +> converting every supported page to a field allowlist remains a future, +> compatibility-sensitive change. ## Executive summary diff --git a/setup.php b/setup.php index cf563f0..432aa9b 100644 --- a/setup.php +++ b/setup.php @@ -84,6 +84,8 @@ function audit_check_upgrade() { db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS object_data LONGBLOB'); db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS outcome varchar(20) NOT NULL DEFAULT 'unknown' AFTER action"); + db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data"); + db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status'); db_execute_prepared('UPDATE plugin_config SET version = ? @@ -126,14 +128,21 @@ function audit_replicate_out($data) { } } } else { - cacti_log('INFO: Audit Log table exists skipping', false, 'REPLICATE'); + cacti_log('INFO: Audit Log table exists, checking schema', false, 'REPLICATE'); } + + db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS object_data LONGBLOB', true, $rcnn_id); + db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS outcome varchar(20) NOT NULL DEFAULT 'unknown' AFTER action", true, $rcnn_id); + db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data", true, $rcnn_id); + db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status', true, $rcnn_id); } return $data; } function audit_poller_bottom() { + audit_retry_external_logs(); + $last_check = read_config_option('audit_last_check'); $now = gmdate('Y-m-d'); @@ -141,8 +150,7 @@ function audit_poller_bottom() { $retention = read_config_option('audit_retention'); if ($retention > 0) { - $cutoff = new DateTimeImmutable('now', new DateTimeZone('UTC')); - $cutoff = $cutoff->sub(new DateInterval('P' . (int) $retention . 'D')); + $cutoff = audit_retention_cutoff($retention); db_execute_prepared('DELETE FROM audit_log WHERE event_time < ?', array($cutoff->format('Y-m-d H:i:s'))); $rows = db_affected_rows(); @@ -168,6 +176,8 @@ function audit_setup_table() { `event_time` timestamp DEFAULT CURRENT_TIMESTAMP, `post` longblob, `object_data` longblob, + `external_status` varchar(20) NOT NULL DEFAULT 'unknown', + `external_error` varchar(1024) DEFAULT NULL, PRIMARY KEY (`id`), KEY `user_id` (`user_id`), KEY `page` (`page`), diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 5aa16a2..2b549b4 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -2,6 +2,7 @@ $controller = file_get_contents(dirname(__DIR__) . '/audit.php'); $javascript = file_get_contents(dirname(__DIR__) . '/js/functions.js'); +$setup = file_get_contents(dirname(__DIR__) . '/setup.php'); $required_controller_guards = array( "\$_SERVER['REQUEST_METHOD'] !== 'POST'", @@ -19,6 +20,23 @@ } } +$required_schema_fragments = array( + 'api_plugin_register_realm(\'audit\', \'audit_manage.php\'', + 'api_plugin_register_hook(\'audit\', \'replicate_out\'', + 'ADD COLUMN IF NOT EXISTS outcome', + 'ADD COLUMN IF NOT EXISTS external_status', + 'ADD COLUMN IF NOT EXISTS external_error', + 'SHOW CREATE TABLE $table', + 'audit_retry_external_logs()' +); + +foreach ($required_schema_fragments as $fragment) { + if (strpos($setup, $fragment) === false) { + fwrite(STDERR, 'Missing schema or replication requirement: ' . $fragment . PHP_EOL); + exit(1); + } +} + if (strpos($javascript, "loadPageNoHeader('audit.php?action=purge") !== false) { fwrite(STDERR, 'Purge must not use the legacy GET request path.' . PHP_EOL); exit(1); diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php index fdd6578..57a222d 100644 --- a/tests/security_functions_test.php +++ b/tests/security_functions_test.php @@ -16,7 +16,8 @@ function audit_test_assert_same($expected, $actual, $message) { 'password' => 'top-secret', 'nested' => array( 'api_token' => 'nested-secret', - 'description' => '' + 'description' => '', + 'opaque_value' => 'Bearer abcdefghijklmnopqrstuvwxyz' ) ); @@ -24,6 +25,7 @@ function audit_test_assert_same($expected, $actual, $message) { audit_test_assert_same('[REDACTED]', $redacted['password'], 'Top-level passwords must be redacted.'); audit_test_assert_same('[REDACTED]', $redacted['nested']['api_token'], 'Nested tokens must be redacted.'); +audit_test_assert_same('[REDACTED]', $redacted['nested']['opaque_value'], 'Secret-shaped values must be redacted even when their key is unknown.'); audit_test_assert_same( '', $redacted['nested']['description'], @@ -49,4 +51,50 @@ function audit_test_assert_same($expected, $actual, $message) { audit_test_assert_same("'=1+1", audit_csv_safe_cell('=1+1'), 'Spreadsheet formulas must be neutralized.'); +$deep = array(); +$cursor = &$deep; +for ($i = 0; $i < 15; $i++) { + $cursor['nested'] = array(); + $cursor = &$cursor['nested']; +} +unset($cursor); + +$bounded_json = audit_json_encode($deep); +if (strpos($bounded_json, 'MAXIMUM DEPTH REACHED') === false) { + fwrite(STDERR, 'Deep request structures must be bounded.' . PHP_EOL); + exit(1); +} + +$invalid_json = audit_json_decode('{invalid', $json_error); +audit_test_assert_same(null, $invalid_json, 'Malformed JSON must return a controlled null result.'); +if ($json_error === null || $json_error === '') { + fwrite(STDERR, 'Malformed JSON must return a useful error.' . PHP_EOL); + exit(1); +} + +$now = new DateTimeImmutable('2026-03-09 12:00:00', new DateTimeZone('America/New_York')); +$cutoff = audit_retention_cutoff(1, $now); +audit_test_assert_same( + '2026-03-08 16:00:00', + $cutoff->format('Y-m-d H:i:s'), + 'Retention must subtract full UTC days consistently across DST.' +); + +audit_test_assert_same( + 'request_completed', + audit_request_outcome(null, 302), + 'Successful redirects must finalize as completed requests.' +); +audit_test_assert_same( + 'request_failed', + audit_request_outcome(array('type' => E_ERROR), 200), + 'Fatal errors must finalize as failed requests.' +); + +$temporary_log = tempnam(sys_get_temp_dir(), 'audit-test-'); +$delivery = audit_append_external_log($temporary_log, "test-record\n"); +audit_test_assert_same('delivered', $delivery['status'], 'A complete external log write must report delivery.'); +audit_test_assert_same("test-record\n", file_get_contents($temporary_log), 'External log content must be complete.'); +unlink($temporary_log); + print "Security helper tests passed.\n"; From 46606809715c81f63defca548ef6101a43275879 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 24 Jul 2026 09:12:21 -0400 Subject: [PATCH 04/36] rename some columns to be more specific --- .github/workflows/plugin-ci-workflow.yml | 8 +++--- CHANGELOG.md | 1 + INFO | 2 +- README.md | 4 +-- audit.php | 14 ++++----- audit_functions.php | 26 ++++++++--------- locales/po/cacti.pot | 6 +++- review.md | 5 +++- setup.php | 36 ++++++++++++++++++++++-- tests/controller_security_test.php | 2 +- tests/security_functions_test.php | 8 +++--- 11 files changed, 75 insertions(+), 37 deletions(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 8e6131a..a5b0a9d 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -180,7 +180,7 @@ jobs: FROM information_schema.columns WHERE table_schema = 'cacti' AND table_name = 'audit_log' - AND column_name IN ('outcome', 'external_status', 'external_error'); + AND column_name IN ('request_status', 'external_status', 'external_error'); ") if [ "$COLUMN_COUNT" -ne 3 ]; then echo "Audit schema security columns are missing" @@ -242,8 +242,8 @@ jobs: exit 1 fi - CLI_OUTCOME=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se "select outcome from audit_log where action = 'cli' order by id desc limit 1;") - if [ "$CLI_OUTCOME" != "attempted" ]; then - echo "Unexpected CLI audit outcome: $CLI_OUTCOME" + CLI_STATUS=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se "select request_status from audit_log where action = 'cli' order by id desc limit 1;") + if [ "$CLI_STATUS" != "started" ]; then + echo "Unexpected CLI request status: $CLI_STATUS" exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd3986..d24dcf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* feature: Rename outcome to request_status with started/completed/failed values * feature: Track and retry failed external audit-log delivery * security: Bound nested request depth, field counts, string sizes, and JSON parsing * security: Escape stored audit data before rendering to prevent stored XSS diff --git a/INFO b/INFO index 6510278..7fda835 100644 --- a/INFO +++ b/INFO @@ -21,7 +21,7 @@ [info] name = audit -version = 1.4 +version = 1.3 longname = Audit Plugin for Cacti author = The Cacti Group email = diff --git a/README.md b/README.md index f085034..0de354d 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ This plugin allows Cacti Administrators to log user change activity * Captures CLI commands and who ran them -Audit entries generated by the `config_insert` hook begin as `attempted` and are -finalized as `request_completed` or `request_failed` when PHP shuts down. A +Audit entries generated by the `config_insert` hook begin with request status +`started` and are finalized as `completed` or `failed` when PHP shuts down. A completed request does not necessarily prove that every page-specific database operation succeeded. diff --git a/audit.php b/audit.php index 82d9be7..49afa4d 100644 --- a/audit.php +++ b/audit.php @@ -81,7 +81,7 @@ function audit_render_event_details($data) { $output .= '
' . __('IP Address:', 'audit') . ' ' . html_escape($data['ip_address']) . ''; $output .= '
' . __('Date:', 'audit') . ' ' . html_escape($data['event_time']) . ''; $output .= '
' . __('Action:', 'audit') . ' ' . html_escape($data['action']) . ''; - $output .= '
' . __('Outcome:', 'audit') . ' ' . html_escape($data['outcome']) . ''; + $output .= '
' . __('Request Status:', 'audit') . ' ' . html_escape($data['request_status']) . ''; $output .= '
' . __('External Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . ''; if ($data['external_error'] != '') { $output .= '
' . __('External Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; @@ -198,7 +198,7 @@ function audit_export_rows() { header('X-Content-Type-Options: nosniff'); $output = fopen('php://output', 'w'); - fputcsv($output, array('page', 'user_id', 'username', 'action', 'outcome', 'external_status', 'external_error', 'ip_address', 'user_agent', 'event_time', 'post'), ',', '"', ''); + fputcsv($output, array('page', 'user_id', 'username', 'action', 'request_status', 'external_status', 'external_error', 'ip_address', 'user_agent', 'event_time', 'post'), ',', '"', ''); foreach($events as $event) { if ($event['action'] == 'cli') { @@ -213,7 +213,7 @@ function audit_export_rows() { $event['user_id'], get_username($event['user_id']), $event['action'], - $event['outcome'], + $event['request_status'], $event['external_status'], $event['external_error'], $event['ip_address'], @@ -432,8 +432,8 @@ function audit_log() { 'sort' => 'ASC', 'tip' => __('The requested Cacti action. Hover over the action to see request data.', 'audit') ), - 'outcome' => array( - 'display' => __('Outcome', 'audit'), + 'request_status' => array( + 'display' => __('Request Status', 'audit'), 'align' => 'left', 'sort' => 'ASC', 'tip' => __('Request processing state; completion does not guarantee that every operation succeeded.', 'audit') @@ -474,7 +474,7 @@ function audit_log() { form_selectable_ecell($e['page'], $e['id']); form_selectable_ecell($e['user_agent'], $e['id']); form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); - form_selectable_ecell($e['outcome'], $e['id']); + form_selectable_ecell($e['request_status'], $e['id']); form_selectable_ecell($e['external_status'], $e['id']); form_selectable_cell(__('N/A', 'audit'), $e['id']); form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); @@ -485,7 +485,7 @@ function audit_log() { form_selectable_cell(filter_value($e['page'], get_request_var('filter')), $e['id']); form_selectable_ecell($e['username'], $e['id']); form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); - form_selectable_ecell($e['outcome'], $e['id']); + form_selectable_ecell($e['request_status'], $e['id']); form_selectable_ecell($e['external_status'], $e['id']); form_selectable_ecell($e['user_agent'], $e['id']); form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); diff --git a/audit_functions.php b/audit_functions.php index 89c9848..78c4739 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -299,7 +299,7 @@ function audit_retry_external_logs() { 'page' => $event['page'], 'user_id' => $event['user_id'], 'action' => $event['action'], - 'outcome' => $event['outcome'], + 'request_status' => $event['request_status'], 'ip_address' => $event['ip_address'], 'user_agent' => $event['user_agent'], 'event_time' => $event['event_time'], @@ -317,27 +317,27 @@ function audit_retry_external_logs() { } } -function audit_request_outcome($error = null, $status_code = 200) { +function audit_request_status($error = null, $status_code = 200) { $fatal_types = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR); if ((is_array($error) && in_array($error['type'] ?? null, $fatal_types, true)) || $status_code >= 400) { - return 'request_failed'; + return 'failed'; } - return 'request_completed'; + return 'completed'; } function audit_finalize_request($id) { $status_code = http_response_code(); $status_code = is_int($status_code) ? $status_code : 200; - $outcome = audit_request_outcome(error_get_last(), $status_code); + $request_status = audit_request_status(error_get_last(), $status_code); db_execute_prepared("UPDATE audit_log - SET outcome = ? + SET request_status = ? WHERE id = ? - AND outcome = 'attempted'", - array($outcome, $id)); + AND request_status = 'started'", + array($request_status, $id)); } @@ -426,9 +426,9 @@ function audit_config_insert() { $base = CACTI_PATH_BASE; } - db_execute_prepared('INSERT INTO audit_log (page, user_id, action, outcome, ip_address, user_agent, event_time, post, object_data, external_status) + db_execute_prepared('INSERT INTO audit_log (page, user_id, action, request_status, ip_address, user_agent, event_time, post, object_data, external_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - array($page, $user_id, $action, 'attempted', $ip_address, $user_agent, $event_time, $post, $object_data, $external_status)); + array($page, $user_id, $action, 'started', $ip_address, $user_agent, $event_time, $post, $object_data, $external_status)); $audit_id = db_fetch_insert_id(); register_shutdown_function('audit_finalize_request', $audit_id); @@ -455,7 +455,7 @@ function audit_config_insert() { 'page' => $page, 'user_id' => $user_id, 'action' => $action, - 'outcome' => 'attempted', + 'request_status' => 'started', 'ip_address' => $ip_address, 'user_agent' => $user_agent, 'event_time' => $event_time, @@ -492,9 +492,9 @@ function audit_config_insert() { strpos($arguments[0], 'script_server.php') === false && strpos($arguments[0], '_process.php') === false) { - db_execute_prepared('INSERT INTO audit_log (page, user_id, action, outcome, ip_address, user_agent, event_time, post, external_status) + db_execute_prepared('INSERT INTO audit_log (page, user_id, action, request_status, ip_address, user_agent, event_time, post, external_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - array($page, $user_id, $action, 'attempted', $ip_address, $user_agent, $event_time, $post, 'not_applicable')); + array($page, $user_id, $action, 'started', $ip_address, $user_agent, $event_time, $post, 'not_applicable')); } } } diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 0d57f0c..ad15bf4 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -314,7 +314,11 @@ msgid "The requested Cacti action. Hover over the action to see request data." msgstr "" #: audit.php -msgid "Outcome" +msgid "Request Status" +msgstr "" + +#: audit.php +msgid "Request Status:" msgstr "" #: audit.php diff --git a/review.md b/review.md index f35b4cf..a24331c 100644 --- a/review.md +++ b/review.md @@ -2,7 +2,7 @@ > Remediation update (branch `code_audit`): the implementation now addresses > AUD-001 through AUD-010, adds focused security-helper coverage for AUD-011, -> and bumps the plugin schema/version to 1.4. The findings below describe the +> and bumps the plugin schema/version to 1.3. The findings below describe the > pre-remediation code that was reviewed and are retained as the audit record. > Full browser/database integration coverage is still recommended before release. > Generic requests are finalized as completed or failed, but operation-specific @@ -10,6 +10,9 @@ > page. Request collection uses recursive redaction and strict size/depth bounds; > converting every supported page to a field allowlist remains a future, > compatibility-sensitive change. +> Request lifecycle state is stored in `request_status` using `started`, +> `completed`, and `failed`; it is intentionally distinct from operation-level +> success. ## Executive summary diff --git a/setup.php b/setup.php index 432aa9b..99e6082 100644 --- a/setup.php +++ b/setup.php @@ -83,7 +83,22 @@ function audit_check_upgrade() { } db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS object_data LONGBLOB'); - db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS outcome varchar(20) NOT NULL DEFAULT 'unknown' AFTER action"); + if (db_column_exists('audit_log', 'outcome')) { + if (!db_column_exists('audit_log', 'request_status')) { + db_execute("ALTER TABLE audit_log CHANGE COLUMN outcome request_status varchar(20) NOT NULL DEFAULT 'unknown'"); + } else { + db_execute("UPDATE audit_log SET request_status = outcome WHERE request_status = 'unknown'"); + db_execute('ALTER TABLE audit_log DROP COLUMN outcome'); + } + } elseif (!db_column_exists('audit_log', 'request_status')) { + db_execute("ALTER TABLE audit_log ADD COLUMN request_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER action"); + } + + db_execute("UPDATE audit_log SET request_status = CASE request_status + WHEN 'attempted' THEN 'started' + WHEN 'request_completed' THEN 'completed' + WHEN 'request_failed' THEN 'failed' + ELSE request_status END"); db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data"); db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status'); @@ -132,7 +147,22 @@ function audit_replicate_out($data) { } db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS object_data LONGBLOB', true, $rcnn_id); - db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS outcome varchar(20) NOT NULL DEFAULT 'unknown' AFTER action", true, $rcnn_id); + if (db_column_exists('audit_log', 'outcome', false, $rcnn_id)) { + if (!db_column_exists('audit_log', 'request_status', false, $rcnn_id)) { + db_execute("ALTER TABLE audit_log CHANGE COLUMN outcome request_status varchar(20) NOT NULL DEFAULT 'unknown'", true, $rcnn_id); + } else { + db_execute("UPDATE audit_log SET request_status = outcome WHERE request_status = 'unknown'", true, $rcnn_id); + db_execute('ALTER TABLE audit_log DROP COLUMN outcome', true, $rcnn_id); + } + } elseif (!db_column_exists('audit_log', 'request_status', false, $rcnn_id)) { + db_execute("ALTER TABLE audit_log ADD COLUMN request_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER action", true, $rcnn_id); + } + + db_execute("UPDATE audit_log SET request_status = CASE request_status + WHEN 'attempted' THEN 'started' + WHEN 'request_completed' THEN 'completed' + WHEN 'request_failed' THEN 'failed' + ELSE request_status END", true, $rcnn_id); db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data", true, $rcnn_id); db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status', true, $rcnn_id); } @@ -170,7 +200,7 @@ function audit_setup_table() { `page` varchar(40) DEFAULT NULL, `user_id` int(10) unsigned DEFAULT NULL, `action` varchar(20) DEFAULT NULL, - `outcome` varchar(20) NOT NULL DEFAULT 'unknown', + `request_status` varchar(20) NOT NULL DEFAULT 'unknown', `ip_address` varchar(40) DEFAULT NULL, `user_agent` varchar(256) DEFAULT NULL, `event_time` timestamp DEFAULT CURRENT_TIMESTAMP, diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 2b549b4..5d9e382 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -23,7 +23,7 @@ $required_schema_fragments = array( 'api_plugin_register_realm(\'audit\', \'audit_manage.php\'', 'api_plugin_register_hook(\'audit\', \'replicate_out\'', - 'ADD COLUMN IF NOT EXISTS outcome', + 'request_status', 'ADD COLUMN IF NOT EXISTS external_status', 'ADD COLUMN IF NOT EXISTS external_error', 'SHOW CREATE TABLE $table', diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php index 57a222d..89c1121 100644 --- a/tests/security_functions_test.php +++ b/tests/security_functions_test.php @@ -81,13 +81,13 @@ function audit_test_assert_same($expected, $actual, $message) { ); audit_test_assert_same( - 'request_completed', - audit_request_outcome(null, 302), + 'completed', + audit_request_status(null, 302), 'Successful redirects must finalize as completed requests.' ); audit_test_assert_same( - 'request_failed', - audit_request_outcome(array('type' => E_ERROR), 200), + 'failed', + audit_request_status(array('type' => E_ERROR), 200), 'Fatal errors must finalize as failed requests.' ); From 6d3afb2f30f80cba867e15b5cb2afd1bde642cd1 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 24 Jul 2026 09:21:51 -0400 Subject: [PATCH 05/36] Support json for external logging This will make it easier for some SIEMS like splunk to ingest the log file --- CHANGELOG.md | 1 + README.md | 6 +++++- audit_functions.php | 36 +++++++++++++++++++++++++++++-- setup.php | 10 +++++++++ tests/security_functions_test.php | 23 ++++++++++++++++++++ 5 files changed, 73 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d24dcf6..5c62f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* feature: Add selectable text or JSON formats for external audit logging * feature: Rename outcome to request_status with started/completed/failed values * feature: Track and retry failed external audit-log delivery * security: Bound nested request depth, field counts, string sizes, and JSON parsing diff --git a/README.md b/README.md index 0de354d..4713134 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,11 @@ directory is named 'audit' and not 'plugin_audit'. Once this is done, you have to goto Configuration -> Settings -> Audit and define data retention and turn on auditing. -You can also enable file based logging for ingestion by Siem or Log analysis tools such as splunk +You can also enable file-based logging for ingestion by SIEM or log-analysis +tools such as Splunk. External records can be written as newline-delimited JSON +(one JSON object per line) or as single-line text using quoted `key="value"` +fields. Control characters in text values are escaped so every event remains on +one line. JSON is the default to preserve the format used by earlier releases. External file delivery is tracked on each database record. Failed appends are retried by the poller in batches and therefore have at-least-once delivery diff --git a/audit_functions.php b/audit_functions.php index 78c4739..3554098 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -240,6 +240,33 @@ function audit_json_decode($json, &$error = null) { } } +function audit_external_log_format($data, $format = 'json') { + if ($format === 'text') { + $fields = array(); + + foreach ($data as $name => $value) { + if (is_array($value) || is_object($value)) { + $value = audit_json_encode($value); + } elseif ($value === null) { + $value = ''; + } elseif (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + + $value = str_replace( + array('\\', "\r", "\n", "\t", '"'), + array('\\\\', '\r', '\n', '\t', '\"'), + (string) $value + ); + $fields[] = $name . '="' . $value . '"'; + } + + return implode(' ', $fields) . "\n"; + } + + return audit_json_encode($data) . "\n"; +} + function audit_csv_safe_cell($value) { $value = (string) $value; @@ -288,6 +315,9 @@ function audit_retry_external_logs() { return; } + $format = read_config_option('audit_log_external_format'); + $format = $format === 'text' ? 'text' : 'json'; + $events = db_fetch_assoc("SELECT * FROM audit_log WHERE external_status = 'failed' @@ -307,7 +337,7 @@ function audit_retry_external_logs() { 'object_data' => $event['object_data'] ); - $message = audit_json_encode($log_data) . "\n"; + $message = audit_external_log_format($log_data, $format); $delivery = audit_append_external_log($path, $message); audit_set_external_status($event['id'], $delivery['status'], $delivery['error']); @@ -419,6 +449,8 @@ function audit_config_insert() { $audit_log = read_config_option('audit_log_external_path'); $external_logging = read_config_option('audit_log_external') == 'on'; $external_status = $external_logging ? 'pending' : 'disabled'; + $external_format = read_config_option('audit_log_external_format'); + $external_format = $external_format === 'text' ? 'text' : 'json'; if (!defined('CACTI_PATH_BASE')) { $base = $config['base_path']; @@ -463,7 +495,7 @@ function audit_config_insert() { 'object_data' => $object_data ); - $log_msg = audit_json_encode($log_data) . "\n"; + $log_msg = audit_external_log_format($log_data, $external_format); $delivery = audit_append_external_log($audit_log, $log_msg); audit_set_external_status($audit_id, $delivery['status'], $delivery['error']); diff --git a/setup.php b/setup.php index 99e6082..537a0f3 100644 --- a/setup.php +++ b/setup.php @@ -334,6 +334,16 @@ function audit_config_settings() { 'method' => 'checkbox', 'default' => 'off' ), + 'audit_log_external_format' => array( + 'friendly_name' => __('External Audit Log Format', 'audit'), + 'description' => __('Select the output format for external audit log records.', 'audit'), + 'method' => 'drop_array', + 'default' => 'json', + 'array' => array( + 'text' => __('Text', 'audit'), + 'json' => __('JSON', 'audit') + ) + ), 'audit_log_external_path' => array( 'friendly_name' => __('External Audit Log Log file Path', 'audit'), 'description' => __('Enter the path to the external audit log file.', 'audit'), diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php index 89c1121..bc879f9 100644 --- a/tests/security_functions_test.php +++ b/tests/security_functions_test.php @@ -91,6 +91,29 @@ function audit_test_assert_same($expected, $actual, $message) { 'Fatal errors must finalize as failed requests.' ); +$external_record = array( + 'event_time' => '2026-07-24 10:00:00', + 'action' => "Update\nDevice", + 'post' => array('id' => 42) +); +$json_record = audit_external_log_format($external_record, 'json'); +audit_test_assert_same("\n", substr($json_record, -1), 'JSON external records must end with a newline.'); +audit_test_assert_same( + $external_record, + json_decode(trim($json_record), true), + 'JSON external records must contain the complete event.' +); +audit_test_assert_same( + 'event_time="2026-07-24 10:00:00" action="Update\nDevice" post="{\"id\":42}"' . "\n", + audit_external_log_format($external_record, 'text'), + 'Text external records must be single-line key/value data with escaped values.' +); +audit_test_assert_same( + $json_record, + audit_external_log_format($external_record, 'unsupported'), + 'Unknown external formats must safely fall back to JSON.' +); + $temporary_log = tempnam(sys_get_temp_dir(), 'audit-test-'); $delivery = audit_append_external_log($temporary_log, "test-record\n"); audit_test_assert_same('delivered', $delivery['status'], 'A complete external log write must report delivery.'); From ab887ac6c87293cd878133392b9a459d69bfb230 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 24 Jul 2026 09:25:21 -0400 Subject: [PATCH 06/36] fix json formatting --- README.md | 4 +++- audit_functions.php | 16 +++++++++++++--- tests/security_functions_test.php | 19 +++++++++++++++---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4713134..a1f0a5a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ You can also enable file-based logging for ingestion by SIEM or log-analysis tools such as Splunk. External records can be written as newline-delimited JSON (one JSON object per line) or as single-line text using quoted `key="value"` fields. Control characters in text values are escaped so every event remains on -one line. JSON is the default to preserve the format used by earlier releases. +one line. In JSON output, `post` and `object_data` are native nested structures, +not JSON-encoded strings. JSON is the default to preserve the format used by +earlier releases. External file delivery is tracked on each database record. Failed appends are retried by the poller in batches and therefore have at-least-once delivery diff --git a/audit_functions.php b/audit_functions.php index 3554098..73a12d6 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -219,8 +219,8 @@ function audit_redact_cli_arguments($arguments) { return $redacted; } -function audit_json_encode($data) { - $json = json_encode(audit_bound_log_data($data), JSON_INVALID_UTF8_SUBSTITUTE, 16); +function audit_json_encode($data, $options = 0) { + $json = json_encode(audit_bound_log_data($data), JSON_INVALID_UTF8_SUBSTITUTE | $options, 16); if ($json === false) { return json_encode(array('audit_encoding_error' => json_last_error_msg())); @@ -264,7 +264,17 @@ function audit_external_log_format($data, $format = 'json') { return implode(' ', $fields) . "\n"; } - return audit_json_encode($data) . "\n"; + foreach (array('post', 'object_data') as $name) { + if (isset($data[$name]) && is_string($data[$name])) { + $decoded = audit_json_decode($data[$name], $error); + + if ($error === null) { + $data[$name] = $decoded; + } + } + } + + return audit_json_encode($data, JSON_UNESCAPED_SLASHES) . "\n"; } function audit_csv_safe_cell($value) { diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php index bc879f9..7bc3dea 100644 --- a/tests/security_functions_test.php +++ b/tests/security_functions_test.php @@ -94,20 +94,31 @@ function audit_test_assert_same($expected, $actual, $message) { $external_record = array( 'event_time' => '2026-07-24 10:00:00', 'action' => "Update\nDevice", - 'post' => array('id' => 42) + 'post' => '{"id":42}', + 'object_data' => '[]' ); $json_record = audit_external_log_format($external_record, 'json'); audit_test_assert_same("\n", substr($json_record, -1), 'JSON external records must end with a newline.'); audit_test_assert_same( - $external_record, + array( + 'event_time' => '2026-07-24 10:00:00', + 'action' => "Update\nDevice", + 'post' => array('id' => 42), + 'object_data' => array() + ), json_decode(trim($json_record), true), - 'JSON external records must contain the complete event.' + 'JSON external records must expose stored JSON fields as native structures.' ); audit_test_assert_same( - 'event_time="2026-07-24 10:00:00" action="Update\nDevice" post="{\"id\":42}"' . "\n", + 'event_time="2026-07-24 10:00:00" action="Update\nDevice" post="{\"id\":42}" object_data="[]"' . "\n", audit_external_log_format($external_record, 'text'), 'Text external records must be single-line key/value data with escaped values.' ); +audit_test_assert_same( + '{"post":"not-json"}' . "\n", + audit_external_log_format(array('post' => 'not-json'), 'json'), + 'Malformed stored JSON fields must remain available as strings.' +); audit_test_assert_same( $json_record, audit_external_log_format($external_record, 'unsupported'), From 6e27cc33a6524b3b81173d44f2f8ffe19d0658b3 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 24 Jul 2026 10:12:01 -0400 Subject: [PATCH 07/36] expand auditing coverage feat: add normalized compliance audit event capture - add event and correlation UUIDs with integrity metadata - record actor, target, outcome, timing, and event classification - deliver finalized request outcomes to external log consumers - audit log views, searches, details, exports, and purges - capture logout and session-timeout events on Cacti 1.2.x - finalize CLI audit events and expand CSV exports - add schema migration, documentation, and tests --- CHANGELOG.md | 5 + INFO | 2 +- README.md | 17 ++ audit.php | 75 +++++++- audit_functions.php | 277 +++++++++++++++++++++++------ setup.php | 79 +++++++- tests/controller_security_test.php | 6 +- tests/security_functions_test.php | 34 ++++ 8 files changed, 431 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c62f10..8954bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ --- develop --- +* feature: Add normalized compliance event identifiers, categories, actors, targets, outcomes, timing, and integrity metadata +* feature: Deliver finalized request outcomes to external log consumers +* feature: Audit audit-log views, searches, event detail access, exports, and purges +* feature: Capture Cacti 1.2.x logout and session-timeout events through the supported logout hook +* feature: Finalize captured CLI activity and make it available to external log delivery * feature: Add selectable text or JSON formats for external audit logging * feature: Rename outcome to request_status with started/completed/failed values * feature: Track and retry failed external audit-log delivery diff --git a/INFO b/INFO index 7fda835..6510278 100644 --- a/INFO +++ b/INFO @@ -21,7 +21,7 @@ [info] name = audit -version = 1.3 +version = 1.4 longname = Audit Plugin for Cacti author = The Cacti Group email = diff --git a/README.md b/README.md index a1f0a5a..b2bd0d8 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,23 @@ External file delivery is tracked on each database record. Failed appends are retried by the poller in batches and therefore have at-least-once delivery semantics; downstream ingestion should deduplicate when necessary. +Version 1.4 records a stable event UUID and request correlation UUID on new +events. External records are written only after request finalization, so SIEM +consumers receive the final request status instead of the earlier transient +`started` state. Consumers should deduplicate on `event_uuid`. + +The normalized fields distinguish request processing from the result of the +requested Cacti operation. `request_status=completed` means that PHP request +processing completed without a fatal error or an HTTP error response. It does +not by itself prove that page-specific validation or database work succeeded. +`operation_outcome` remains `unknown` unless an authoritative Cacti 1.2.x hook +or plugin-owned operation supplies the result. + +The plugin also audits access to its own event list, searches, event details, +exports and purge operations. Logout and session-timeout events are captured +through Cacti's supported `logout_pre_session_destroy` hook. Database-level +changes, API activity and MFA events are outside the current Cacti 1.2.x scope. + ## Possible Bugs If you figure out this problem, see the Cacti forums! diff --git a/audit.php b/audit.php index 49afa4d..67e7efd 100644 --- a/audit.php +++ b/audit.php @@ -63,6 +63,13 @@ break; } + audit_record_event('audit.event.viewed', array( + 'event_category' => 'audit', + 'target_type' => 'audit_event', + 'target_id' => $data['event_uuid'] != '' ? $data['event_uuid'] : $data['id'], + 'details' => array('record_id' => $data['id']) + )); + $output = audit_render_event_details($data); echo $output; @@ -81,7 +88,10 @@ function audit_render_event_details($data) { $output .= '
' . __('IP Address:', 'audit') . ' ' . html_escape($data['ip_address']) . ''; $output .= '
' . __('Date:', 'audit') . ' ' . html_escape($data['event_time']) . ''; $output .= '
' . __('Action:', 'audit') . ' ' . html_escape($data['action']) . ''; + $output .= '
' . __('Event Type:', 'audit') . ' ' . html_escape($data['event_type']) . ''; + $output .= '
' . __('Event ID:', 'audit') . ' ' . html_escape($data['event_uuid']) . ''; $output .= '
' . __('Request Status:', 'audit') . ' ' . html_escape($data['request_status']) . ''; + $output .= '
' . __('Operation Outcome:', 'audit') . ' ' . html_escape($data['operation_outcome']) . ''; $output .= '
' . __('External Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . ''; if ($data['external_error'] != '') { $output .= '
' . __('External Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; @@ -153,6 +163,13 @@ function audit_render_value($value) { function audit_purge() { db_execute('TRUNCATE TABLE audit_log'); + audit_record_event('audit.log.purged', array( + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'purge', + 'target_type' => 'audit_log' + )); + $_SESSION['audit_message'] = __('Audit Log Purged by %s', get_username($_SESSION['sess_user_id']), 'audit'); cacti_log('NOTE: Audit Log Purged by ' . get_username($_SESSION['sess_user_id']), false, 'WEBUI'); @@ -192,13 +209,25 @@ function audit_export_rows() { $sql_where", $sql_params); + audit_record_event('audit.log.exported', array( + 'event_category' => 'audit', + 'action' => 'export', + 'target_type' => 'audit_log', + 'details' => array( + 'row_count' => cacti_sizeof($events), + 'filter' => get_request_var('filter'), + 'event_page' => get_request_var('event_page'), + 'user_id' => get_request_var('user_id') + ) + )); + if (cacti_sizeof($events)) { header('Content-Disposition: attachment; filename=audit_export.csv'); header('Content-Type: text/csv; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); $output = fopen('php://output', 'w'); - fputcsv($output, array('page', 'user_id', 'username', 'action', 'request_status', 'external_status', 'external_error', 'ip_address', 'user_agent', 'event_time', 'post'), ',', '"', ''); + fputcsv($output, array('event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'page', 'user_id', 'username', 'action', 'request_status', 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', 'external_status', 'external_error', 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', 'completed_time', 'duration_ms', 'integrity_hash', 'post', 'details'), ',', '"', ''); foreach($events as $event) { if ($event['action'] == 'cli') { @@ -208,19 +237,34 @@ function audit_export_rows() { $poster = is_array($post) ? json_encode($post, JSON_INVALID_UTF8_SUBSTITUTE) : $event['post']; } - fputcsv($output, array_map('audit_csv_safe_cell', array( - $event['page'], + fputcsv($output, array_map('audit_csv_safe_cell', array( + $event['event_uuid'], + $event['correlation_id'], + $event['event_type'], + $event['event_category'], + $event['severity'], + $event['page'], $event['user_id'], get_username($event['user_id']), $event['action'], - $event['request_status'], - $event['external_status'], + $event['request_status'], + $event['operation_outcome'], + $event['outcome_reason'], + $event['target_type'], + $event['target_id'], + $event['external_status'], $event['external_error'], $event['ip_address'], - $event['user_agent'], - $event['event_time'], - $poster - )), ',', '"', ''); + $event['user_agent'], + $event['http_method'], + $event['http_status'], + $event['event_time'], + $event['completed_time'], + $event['duration_ms'], + $event['integrity_hash'], + $poster, + $event['details'] + )), ',', '"', ''); } fclose($output); @@ -275,6 +319,19 @@ function audit_log() { global $item_rows; audit_process_request_vars(); + $has_filters = get_request_var('filter') != '' || + get_request_var('event_page') != '-1' || + (!isempty_request_var('user_id') && get_request_var('user_id') > '-1'); + audit_record_event($has_filters ? 'audit.log.searched' : 'audit.log.viewed', array( + 'event_category' => 'audit', + 'action' => $has_filters ? 'search' : 'view', + 'target_type' => 'audit_log', + 'details' => array( + 'filter' => get_request_var('filter'), + 'event_page' => get_request_var('event_page'), + 'user_id' => get_request_var('user_id') + ) + )); if (get_request_var('rows') == '-1') { $rows = read_config_option('num_rows_table'); diff --git a/audit_functions.php b/audit_functions.php index 73a12d6..882f50c 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -240,6 +240,84 @@ function audit_json_decode($json, &$error = null) { } } +function audit_uuid_v4() { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + $hex = bin2hex($bytes); + + return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . + substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20); +} + +function audit_request_correlation_id() { + static $correlation_id; + + if ($correlation_id === null) { + $correlation_id = audit_uuid_v4(); + } + + return $correlation_id; +} + +function audit_utc_time($microtime = null) { + $microtime = $microtime === null ? microtime(true) : $microtime; + $seconds = (int) $microtime; + $micros = (int) round(($microtime - $seconds) * 1000000); + + if ($micros >= 1000000) { + $seconds++; + $micros = 0; + } + + return gmdate('Y-m-d H:i:s', $seconds) . '.' . sprintf('%06d', $micros); +} + +function audit_event_integrity_hash($event) { + $material = array( + 'event_uuid' => $event['event_uuid'] ?? '', + 'correlation_id' => $event['correlation_id'] ?? '', + 'event_type' => $event['event_type'] ?? '', + 'user_id' => $event['user_id'] ?? 0, + 'action' => $event['action'] ?? '', + 'event_time' => $event['event_time'] ?? '', + 'operation_outcome'=> $event['operation_outcome'] ?? '', + 'target_type' => $event['target_type'] ?? '', + 'target_id' => $event['target_id'] ?? '', + 'details' => $event['details'] ?? '' + ); + + return hash('sha256', audit_json_encode($material, JSON_UNESCAPED_SLASHES)); +} + +function audit_event_type_for_request($page, $action) { + $page_name = preg_replace('/\.php$/', '', (string) $page); + $page_name = preg_replace('/[^a-z0-9_]+/i', '_', $page_name); + $verb = preg_replace('/[^a-z0-9_]+/i', '_', strtolower((string) $action)); + $verb = trim($verb, '_'); + + return 'cacti.' . ($page_name !== '' ? $page_name : 'request') . '.' . + ($verb !== '' && $verb !== 'none' ? $verb : 'submitted'); +} + +function audit_external_event_data($event) { + $fields = array( + 'id', 'event_uuid', 'correlation_id', 'event_type', 'event_category', + 'severity', 'actor_type', 'page', 'user_id', 'action', 'request_status', + 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', + 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', + 'completed_time', 'duration_ms', 'post', 'object_data', 'details', + 'previous_hash', 'integrity_hash' + ); + $data = array(); + + foreach ($fields as $field) { + $data[$field] = $event[$field] ?? null; + } + + return $data; +} + function audit_external_log_format($data, $format = 'json') { if ($format === 'text') { $fields = array(); @@ -264,7 +342,7 @@ function audit_external_log_format($data, $format = 'json') { return implode(' ', $fields) . "\n"; } - foreach (array('post', 'object_data') as $name) { + foreach (array('post', 'object_data', 'details') as $name) { if (isset($data[$name]) && is_string($data[$name])) { $decoded = audit_json_decode($data[$name], $error); @@ -310,9 +388,35 @@ function audit_append_external_log($path, $message) { function audit_set_external_status($id, $status, $error = '') { db_execute_prepared('UPDATE audit_log - SET external_status = ?, external_error = ? + SET external_status = ?, + external_error = ?, + external_attempts = external_attempts + 1, + external_last_attempt = UTC_TIMESTAMP(6), + external_delivered_time = CASE WHEN ? = "delivered" THEN UTC_TIMESTAMP(6) ELSE external_delivered_time END WHERE id = ?', - array($status, $error, $id)); + array($status, $error, $status, $id)); +} + +function audit_deliver_external_event($id) { + if (read_config_option('audit_log_external') != 'on') { + return; + } + + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id)); + if (!cacti_sizeof($event) || $event['request_status'] == 'started') { + return; + } + + $path = read_config_option('audit_log_external_path'); + if ($path == '' || !is_file($path) || is_link($path)) { + audit_set_external_status($id, 'failed', 'Destination is not a regular file or is a symbolic link.'); + return; + } + + $format = read_config_option('audit_log_external_format') === 'text' ? 'text' : 'json'; + $message = audit_external_log_format(audit_external_event_data($event), $format); + $delivery = audit_append_external_log($path, $message); + audit_set_external_status($id, $delivery['status'], $delivery['error']); } function audit_retry_external_logs() { @@ -330,24 +434,13 @@ function audit_retry_external_logs() { $events = db_fetch_assoc("SELECT * FROM audit_log - WHERE external_status = 'failed' + WHERE external_status IN ('pending', 'failed') + AND request_status <> 'started' ORDER BY id LIMIT 100"); foreach ($events as $event) { - $log_data = array( - 'page' => $event['page'], - 'user_id' => $event['user_id'], - 'action' => $event['action'], - 'request_status' => $event['request_status'], - 'ip_address' => $event['ip_address'], - 'user_agent' => $event['user_agent'], - 'event_time' => $event['event_time'], - 'post' => $event['post'], - 'object_data' => $event['object_data'] - ); - - $message = audit_external_log_format($log_data, $format); + $message = audit_external_log_format(audit_external_event_data($event), $format); $delivery = audit_append_external_log($path, $message); audit_set_external_status($event['id'], $delivery['status'], $delivery['error']); @@ -368,16 +461,89 @@ function audit_request_status($error = null, $status_code = 200) { return 'completed'; } -function audit_finalize_request($id) { +function audit_finalize_request($id, $started_at = null) { $status_code = http_response_code(); $status_code = is_int($status_code) ? $status_code : 200; $request_status = audit_request_status(error_get_last(), $status_code); + $outcome = $request_status == 'failed' ? 'failure' : 'unknown'; + $duration_ms = $started_at === null ? null : max(0, (int) round((microtime(true) - $started_at) * 1000)); + $completed_time = audit_utc_time(); db_execute_prepared("UPDATE audit_log - SET request_status = ? + SET request_status = ?, + operation_outcome = CASE WHEN operation_outcome = 'unknown' THEN ? ELSE operation_outcome END, + http_status = ?, + completed_time = ?, + duration_ms = ? WHERE id = ? AND request_status = 'started'", - array($request_status, $id)); + array($request_status, $outcome, $status_code, $completed_time, $duration_ms, $id)); + + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id)); + if (cacti_sizeof($event)) { + db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', + array(audit_event_integrity_hash($event), $id)); + } + + audit_deliver_external_event($id); +} + +function audit_record_event($event_type, $options = array()) { + if (read_config_option('audit_enabled') != 'on') { + return 0; + } + + $event_uuid = audit_uuid_v4(); + $correlation_id = $options['correlation_id'] ?? audit_request_correlation_id(); + $user_id = $options['user_id'] ?? ($_SESSION['sess_user_id'] ?? 0); + $page = $options['page'] ?? basename($_SERVER['SCRIPT_NAME'] ?? 'cli'); + $event_suffix = strrchr($event_type, '.'); + $action = $options['action'] ?? ($event_suffix === false ? $event_type : substr($event_suffix, 1)); + $event_time = $options['event_time'] ?? audit_utc_time(); + $details = audit_json_encode(audit_redact_sensitive_data($options['details'] ?? array())); + $external = read_config_option('audit_log_external') == 'on'; + $ip_address = $options['ip_address'] ?? (function_exists('get_client_addr') ? get_client_addr() : ''); + $user_agent = $options['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? ''); + + db_execute_prepared('INSERT INTO audit_log ( + page, user_id, action, request_status, ip_address, user_agent, event_time, + post, object_data, external_status, event_uuid, correlation_id, event_type, + event_category, severity, actor_type, target_type, target_id, + operation_outcome, outcome_reason, http_method, http_status, + completed_time, duration_ms, details + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + array( + $page, $user_id, $action, 'completed', $ip_address, $user_agent, $event_time, + '{}', '[]', $external ? 'pending' : 'disabled', $event_uuid, $correlation_id, + $event_type, $options['event_category'] ?? 'security', + $options['severity'] ?? 'info', $options['actor_type'] ?? ($user_id ? 'user' : 'system'), + $options['target_type'] ?? null, isset($options['target_id']) ? (string) $options['target_id'] : null, + $options['operation_outcome'] ?? 'success', $options['outcome_reason'] ?? null, + $options['http_method'] ?? ($_SERVER['REQUEST_METHOD'] ?? null), + $options['http_status'] ?? null, $options['completed_time'] ?? $event_time, + $options['duration_ms'] ?? 0, $details + )); + + $id = db_fetch_insert_id(); + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id)); + if (cacti_sizeof($event)) { + db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', + array(audit_event_integrity_hash($event), $id)); + } + audit_deliver_external_event($id); + + return $id; +} + +function audit_logout_pre_session_destroy() { + $reason = get_nfilter_request_var('action', 'user'); + $type = $reason == 'timeout' ? 'authentication.session.expired' : 'authentication.logout'; + + audit_record_event($type, array( + 'event_category' => 'authentication', + 'action' => $reason == 'timeout' ? 'timeout' : 'logout', + 'details' => array('reason' => $reason) + )); } @@ -386,6 +552,7 @@ function audit_config_insert() { global $action, $config; if (audit_log_valid_event()) { + $started_at = microtime(true); /* prepare post */ $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW); $post = is_array($post) ? $post : array(); @@ -412,10 +579,11 @@ function audit_config_insert() { $drop_action = false; } + $target_id = $post['id'] ?? null; $post = audit_json_encode($post); $page = basename($_SERVER['SCRIPT_NAME']); $user_id = (isset($_SESSION['sess_user_id']) ? $_SESSION['sess_user_id'] : 0); - $event_time = date('Y-m-d H:i:s'); + $event_time = audit_utc_time($started_at); /* Retrieve IP address */ $ip_address = get_client_addr(); @@ -459,8 +627,6 @@ function audit_config_insert() { $audit_log = read_config_option('audit_log_external_path'); $external_logging = read_config_option('audit_log_external') == 'on'; $external_status = $external_logging ? 'pending' : 'disabled'; - $external_format = read_config_option('audit_log_external_format'); - $external_format = $external_format === 'text' ? 'text' : 'json'; if (!defined('CACTI_PATH_BASE')) { $base = $config['base_path']; @@ -468,11 +634,25 @@ function audit_config_insert() { $base = CACTI_PATH_BASE; } - db_execute_prepared('INSERT INTO audit_log (page, user_id, action, request_status, ip_address, user_agent, event_time, post, object_data, external_status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - array($page, $user_id, $action, 'started', $ip_address, $user_agent, $event_time, $post, $object_data, $external_status)); + $event_uuid = audit_uuid_v4(); + $correlation_id = audit_request_correlation_id(); + $event_type = audit_event_type_for_request($page, $action); + $category = in_array($page, array('user_admin.php', 'user_group_admin.php'), true) ? 'identity_access' : 'configuration'; + db_execute_prepared('INSERT INTO audit_log ( + page, user_id, action, request_status, ip_address, user_agent, event_time, + post, object_data, external_status, event_uuid, correlation_id, event_type, + event_category, severity, actor_type, target_type, target_id, + operation_outcome, http_method + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + array( + $page, $user_id, $action, 'started', $ip_address, $user_agent, $event_time, + $post, $object_data, $external_status, $event_uuid, $correlation_id, + $event_type, $category, 'info', $user_id ? 'user' : 'system', + preg_replace('/\.php$/', '', $page), $target_id, 'unknown', + $_SERVER['REQUEST_METHOD'] ?? null + )); $audit_id = db_fetch_insert_id(); - register_shutdown_function('audit_finalize_request', $audit_id); + register_shutdown_function('audit_finalize_request', $audit_id, $started_at); if ($external_logging && $audit_log == '') { set_config_option('audit_log_external_path', $base . '/log/audit.log'); @@ -492,39 +672,20 @@ function audit_config_insert() { } } - if ($external_logging && $audit_log != '' && is_file($audit_log) && !is_link($audit_log)) { - $log_data = array( - 'page' => $page, - 'user_id' => $user_id, - 'action' => $action, - 'request_status' => 'started', - 'ip_address' => $ip_address, - 'user_agent' => $user_agent, - 'event_time' => $event_time, - 'post' => $post, - 'object_data' => $object_data - ); - - $log_msg = audit_external_log_format($log_data, $external_format); - $delivery = audit_append_external_log($audit_log, $log_msg); - audit_set_external_status($audit_id, $delivery['status'], $delivery['error']); - - if ($delivery['status'] != 'delivered') { - cacti_log(sprintf('ERROR: Unable to append a complete record to Audit Log file \'%s\': %s', $audit_log, $delivery['error']), false, 'AUDIT'); - } - } elseif ($external_logging && $audit_log != '') { + if ($external_logging && $audit_log != '' && (!is_file($audit_log) || is_link($audit_log))) { $error = 'Destination is not a regular file or is a symbolic link.'; audit_set_external_status($audit_id, 'failed', $error); cacti_log(sprintf('ERROR: Audit Log file \'%s\' is not a regular file or is a symbolic link.', $audit_log), false, 'AUDIT'); } } elseif (isset($_SERVER['argv']) && cacti_sizeof($_SERVER['argv'])) { + $started_at = microtime(true); $arguments = audit_redact_cli_arguments($_SERVER['argv']); $page = basename($arguments[0]); $user_id = 0; $action = 'cli'; $ip_address = getHostByName(php_uname('n')); $user_agent = get_current_user(); - $event_time = date('Y-m-d H:i:s'); + $event_time = audit_utc_time($started_at); $post = implode(' ', $arguments); /* don't insert poller records */ @@ -534,9 +695,21 @@ function audit_config_insert() { strpos($arguments[0], 'script_server.php') === false && strpos($arguments[0], '_process.php') === false) { - db_execute_prepared('INSERT INTO audit_log (page, user_id, action, request_status, ip_address, user_agent, event_time, post, external_status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - array($page, $user_id, $action, 'started', $ip_address, $user_agent, $event_time, $post, 'not_applicable')); + $external_status = read_config_option('audit_log_external') == 'on' ? 'pending' : 'disabled'; + db_execute_prepared('INSERT INTO audit_log ( + page, user_id, action, request_status, ip_address, user_agent, event_time, + post, object_data, external_status, event_uuid, correlation_id, event_type, + event_category, severity, actor_type, target_type, target_id, + operation_outcome + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + array( + $page, $user_id, $action, 'started', $ip_address, $user_agent, + $event_time, $post, '[]', $external_status, audit_uuid_v4(), + audit_request_correlation_id(), 'cacti.cli.executed', 'system', + 'info', 'system', 'cli_command', $page, 'unknown' + )); + $audit_id = db_fetch_insert_id(); + register_shutdown_function('audit_finalize_request', $audit_id, $started_at); } } } diff --git a/setup.php b/setup.php index 537a0f3..5ccb4e3 100644 --- a/setup.php +++ b/setup.php @@ -32,6 +32,7 @@ function plugin_audit_install() { api_plugin_register_hook('audit', 'draw_navigation_text', 'audit_draw_navigation_text', 'setup.php'); api_plugin_register_hook('audit', 'utilities_array', 'audit_utilities_array', 'setup.php'); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php'); + api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php'); /* hook for table replication */ api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php'); @@ -101,6 +102,7 @@ function audit_check_upgrade() { ELSE request_status END"); db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data"); db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status'); + audit_upgrade_event_schema(); db_execute_prepared('UPDATE plugin_config SET version = ? @@ -118,6 +120,7 @@ function audit_check_upgrade() { /* hook for table replication */ api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php', '1'); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php', 1); + api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php', 1); api_plugin_register_realm('audit', 'audit_manage.php', __('Manage Cacti Audit Log', 'audit'), 1); } } @@ -165,6 +168,7 @@ function audit_replicate_out($data) { ELSE request_status END", true, $rcnn_id); db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data", true, $rcnn_id); db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status', true, $rcnn_id); + audit_upgrade_event_schema($rcnn_id); } return $data; @@ -208,18 +212,91 @@ function audit_setup_table() { `object_data` longblob, `external_status` varchar(20) NOT NULL DEFAULT 'unknown', `external_error` varchar(1024) DEFAULT NULL, + `event_uuid` char(36) DEFAULT NULL, + `correlation_id` char(36) DEFAULT NULL, + `event_type` varchar(100) NOT NULL DEFAULT 'cacti.request', + `event_category` varchar(40) NOT NULL DEFAULT 'configuration', + `severity` varchar(12) NOT NULL DEFAULT 'info', + `actor_type` varchar(20) NOT NULL DEFAULT 'user', + `target_type` varchar(64) DEFAULT NULL, + `target_id` varchar(128) DEFAULT NULL, + `operation_outcome` varchar(20) NOT NULL DEFAULT 'unknown', + `outcome_reason` varchar(255) DEFAULT NULL, + `http_method` varchar(10) DEFAULT NULL, + `http_status` smallint unsigned DEFAULT NULL, + `completed_time` datetime(6) DEFAULT NULL, + `duration_ms` bigint unsigned DEFAULT NULL, + `details` longblob, + `previous_hash` char(64) DEFAULT NULL, + `integrity_hash` char(64) DEFAULT NULL, + `external_attempts` int unsigned NOT NULL DEFAULT 0, + `external_last_attempt` datetime(6) DEFAULT NULL, + `external_delivered_time` datetime(6) DEFAULT NULL, PRIMARY KEY (`id`), KEY `user_id` (`user_id`), KEY `page` (`page`), KEY `ip_address` (`ip_address`), KEY `event_time` (`event_time`), - KEY `action` (`action`)) + KEY `action` (`action`), + UNIQUE KEY `event_uuid` (`event_uuid`), + KEY `correlation_id` (`correlation_id`), + KEY `event_type` (`event_type`), + KEY `operation_outcome` (`operation_outcome`), + KEY `external_status` (`external_status`)) ENGINE=InnoDB COMMENT='Audit Log for all GUI activities'"); return true; } +function audit_upgrade_event_schema($rcnn_id = false) { + $remote = $rcnn_id !== false; + $args = $remote ? array(true, $rcnn_id) : array(); + $columns = array( + "event_uuid char(36) DEFAULT NULL", + "correlation_id char(36) DEFAULT NULL", + "event_type varchar(100) NOT NULL DEFAULT 'cacti.request'", + "event_category varchar(40) NOT NULL DEFAULT 'configuration'", + "severity varchar(12) NOT NULL DEFAULT 'info'", + "actor_type varchar(20) NOT NULL DEFAULT 'user'", + "target_type varchar(64) DEFAULT NULL", + "target_id varchar(128) DEFAULT NULL", + "operation_outcome varchar(20) NOT NULL DEFAULT 'unknown'", + "outcome_reason varchar(255) DEFAULT NULL", + "http_method varchar(10) DEFAULT NULL", + "http_status smallint unsigned DEFAULT NULL", + "completed_time datetime(6) DEFAULT NULL", + "duration_ms bigint unsigned DEFAULT NULL", + "details longblob", + "previous_hash char(64) DEFAULT NULL", + "integrity_hash char(64) DEFAULT NULL", + "external_attempts int unsigned NOT NULL DEFAULT 0", + "external_last_attempt datetime(6) DEFAULT NULL", + "external_delivered_time datetime(6) DEFAULT NULL" + ); + + foreach ($columns as $definition) { + call_user_func_array('db_execute', array_merge( + array('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS ' . $definition), + $args + )); + } + + $indexes = array( + 'event_uuid' => array('UNIQUE INDEX', array('event_uuid')), + 'correlation_id' => array('INDEX', array('correlation_id')), + 'event_type' => array('INDEX', array('event_type')), + 'operation_outcome' => array('INDEX', array('operation_outcome')), + 'external_status' => array('INDEX', array('external_status')) + ); + + foreach ($indexes as $name => $definition) { + if (!db_index_exists('audit_log', $name, false, $remote ? $rcnn_id : false)) { + db_add_index('audit_log', $definition[0], $name, $definition[1], true, $remote ? $rcnn_id : false); + } + } +} + function plugin_audit_version() { global $config; $info = parse_ini_file($config['base_path'] . '/plugins/audit/INFO', true); diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 5d9e382..0f38d6d 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -27,7 +27,11 @@ 'ADD COLUMN IF NOT EXISTS external_status', 'ADD COLUMN IF NOT EXISTS external_error', 'SHOW CREATE TABLE $table', - 'audit_retry_external_logs()' + 'audit_retry_external_logs()', + 'logout_pre_session_destroy', + 'event_uuid char(36)', + 'operation_outcome', + 'external_attempts' ); foreach ($required_schema_fragments as $fragment) { diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php index 7bc3dea..a87b1b3 100644 --- a/tests/security_functions_test.php +++ b/tests/security_functions_test.php @@ -91,6 +91,40 @@ function audit_test_assert_same($expected, $actual, $message) { 'Fatal errors must finalize as failed requests.' ); +$uuid = audit_uuid_v4(); +if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid)) { + fwrite(STDERR, 'Event identifiers must be RFC 4122 version 4 UUIDs.' . PHP_EOL); + exit(1); +} + +audit_test_assert_same( + 'cacti.user_admin.save', + audit_event_type_for_request('user_admin.php', 'Save'), + 'Request event types must be normalized for downstream consumers.' +); +audit_test_assert_same( + 'cacti.host.submitted', + audit_event_type_for_request('host.php', 'none'), + 'Requests without a specific action must use the submitted event verb.' +); + +$hash_event = array( + 'event_uuid' => $uuid, + 'correlation_id' => audit_uuid_v4(), + 'event_type' => 'cacti.test.completed', + 'user_id' => 1, + 'action' => 'test', + 'event_time' => '2026-07-24 10:00:00', + 'operation_outcome' => 'success', + 'details' => '{}' +); +$first_hash = audit_event_integrity_hash($hash_event); +$hash_event['operation_outcome'] = 'failure'; +if ($first_hash === audit_event_integrity_hash($hash_event)) { + fwrite(STDERR, 'Integrity hashes must change when protected event fields change.' . PHP_EOL); + exit(1); +} + $external_record = array( 'event_time' => '2026-07-24 10:00:00', 'action' => "Update\nDevice", From d3fb6c5c2237975cae7ca302491e58cdee4b5d95 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Fri, 24 Jul 2026 11:35:01 -0400 Subject: [PATCH 08/36] remove file --- review.md | 326 ------------------------------------------------------ 1 file changed, 326 deletions(-) delete mode 100644 review.md diff --git a/review.md b/review.md deleted file mode 100644 index a24331c..0000000 --- a/review.md +++ /dev/null @@ -1,326 +0,0 @@ -# Audit Plugin Code Review - -> Remediation update (branch `code_audit`): the implementation now addresses -> AUD-001 through AUD-010, adds focused security-helper coverage for AUD-011, -> and bumps the plugin schema/version to 1.3. The findings below describe the -> pre-remediation code that was reviewed and are retained as the audit record. -> Full browser/database integration coverage is still recommended before release. -> Generic requests are finalized as completed or failed, but operation-specific -> success cannot be proven without corresponding completion hooks in each Cacti -> page. Request collection uses recursive redaction and strict size/depth bounds; -> converting every supported page to a field allowlist remains a future, -> compatibility-sensitive change. -> Request lifecycle state is stored in `request_status` using `started`, -> `completed`, and `failed`; it is intentionally distinct from operation-level -> success. - -## Executive summary - -The plugin is small and readable, uses prepared statements for its primary insert and object lookups, and passes PHP syntax checks. However, it should not be released in its current form. The audit found two high-severity security defects, several confidentiality and audit-integrity weaknesses, broken remote-replication code, and no meaningful automated coverage for the sensitive paths. - -The highest priorities are: - -1. Escape every value rendered from `audit_log`. -2. Make purge a CSRF-protected POST operation with explicit authorization. -3. Replace the current credential redaction with recursive, allowlist-oriented collection, including CLI arguments. -4. Produce exports with a real CSV writer and neutralize spreadsheet formulas. -5. Define whether records represent attempts or successful changes and record that outcome accurately. - -## Scope and methodology - -Reviewed: - -- `.github/copilot-instructions.md` -- `setup.php` -- `audit.php` -- `audit_functions.php` -- `js/functions.js` -- `INFO`, `README.md`, `CHANGELOG.md` -- `.github/workflows/plugin-ci-workflow.yml` -- localization build assets and repository layout - -The review traced install/upgrade/uninstall, hook registration, GUI and CLI event capture, database writes and reads, record-detail rendering, filtering, export, purge, retention, external-file logging, and remote replication. Cacti core helpers in the adjacent Cacti checkout were consulted to verify authorization, CSRF, sorting, validation, and HTML helper behavior. - -Validation performed: - -```text -find . -name '*.php' -print0 | xargs -0 -n1 php -l -``` - -Result: every PHP file passed syntax validation. - -No plugin unit or functional test suite is present. The CI workflow installs the plugin, checks syntax, runs the poller, and asserts that at least one CLI audit row exists; it does not exercise the web UI or any negative/security cases. - -## Findings - -### AUD-001 — High — Stored XSS in audit detail and list views - -**Evidence** - -- `audit.php:54-60` directly concatenates CLI record fields into HTML. -- `audit.php:77-81` directly concatenates page, username, IP address, date, and action into HTML. -- `audit.php:95-104` directly renders request field names and values. -- `audit.php:121-123` embeds JSON record data inside `
` without HTML escaping.
-- `audit.php:432-448` passes database values such as `user_agent`, username, page, IP, and action to `form_selectable_cell()`. Cacti's `form_selectable_cell()` does not escape its content; `form_selectable_ecell()` is the escaping variant.
-- `audit_functions.php:131-158` stores attacker-influenced request data, and `audit_functions.php:167` stores the request's `User-Agent`.
-
-**Impact**
-
-An authenticated user who can cause an audited request can persist HTML or JavaScript in a request value or user-agent string. When an administrator with the audit realm views the list or hovers over the event, that content is inserted into the DOM. This can execute script in the administrator's Cacti session and may permit administrative account compromise.
-
-**Recommendation**
-
-- Escape all scalar values at the final HTML output boundary with `html_escape()`/`htmlspecialchars(..., ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')`.
-- Recursively render arrays and objects as escaped text, never as markup.
-- Use `form_selectable_ecell()` for plain database values. Keep `form_selectable_cell()` only where the plugin itself constructs trusted markup, and escape the interpolated action inside that markup.
-- Return detail data as JSON and construct text nodes client-side, or keep the HTML endpoint but apply centralized contextual escaping.
-- Add regression tests containing `
 	= $config['dead_letter_warning'] ||
+		$health['oldest_pending_seconds'] >= $config['pending_age_warning']
+	);
+
+	html_start_box(__('Remote Syslog Delivery', 'audit'), '100%', '', '3', 'center', '');
+
+	print "";
+	print '' . __('Status', 'audit') . '';
+	print '' . html_escape(!$enabled ? __('Disabled', 'audit') : ($unhealthy ? __('Unhealthy', 'audit') : __('Healthy', 'audit'))) . '';
+	print '' . __('Pending', 'audit') . '' . (int) $health['pending'] . '';
+	print '' . __('Retry', 'audit') . '' . (int) $health['retry'] . '';
+	print '' . __('Dead-letter', 'audit') . '' . (int) $health['dead_letter'] . '';
+	print '' . __('Sent (Unconfirmed)', 'audit') . '' . (int) $health['sent_unconfirmed'] . '';
+	print '';
+
+	print "";
+	print '' . __('Oldest Pending', 'audit') . '' . (int) $health['oldest_pending_seconds'] . ' ' . html_escape(__('seconds', 'audit')) . '';
+	print '' . __('Last Attempt', 'audit') . '' . html_escape($health['last_attempt'] ?? __('Never', 'audit')) . '';
+	print '' . __('Last Socket Write', 'audit') . '' . html_escape($health['last_sent'] ?? __('Never', 'audit')) . '';
+	print '';
+	print " ';
+	if ($health['dead_letter'] > 0) {
+		print "';
+	}
+	print '';
+
+	if ($enabled && !$config['valid']) {
+		print "" . __esc('Configuration Error:', 'audit') . ' ' .
+			html_escape(implode(', ', $config['errors'])) . '';
+	} elseif ($health['last_error'] !== null) {
+		print "" . __esc('Last Error:', 'audit') . ' ' .
+			html_escape($health['last_error']) . '';
+	}
+
+	html_end_box();
+}
diff --git a/audit_functions.php b/audit_functions.php
index 0d56410..90e7eef 100644
--- a/audit_functions.php
+++ b/audit_functions.php
@@ -1,5 +1,7 @@
  $value) {
+		if (strpos((string) $name, 'audit_syslog_') === 0) {
+			$has_syslog_fields = true;
+			break;
+		}
+	}
+
+	if (!$has_syslog_fields) {
+		return;
+	}
+
+	if (!audit_user_is_admin()) {
+		audit_record_event('audit.syslog.configuration.denied', array(
+			'event_category' => 'audit',
+			'severity' => 'warning',
+			'action' => 'save',
+			'target_type' => 'syslog_configuration',
+			'operation_outcome' => 'failure',
+			'outcome_reason' => 'audit_admin_required'
+		));
+		http_response_code(403);
+		exit;
+	}
+
+	$names = array(
+		'receiver', 'port', 'transport', 'format', 'facility', 'application',
+		'node_id', 'timeout', 'udp_max_size', 'retry_base', 'retry_max',
+		'max_attempts', 'batch_size', 'pending_age_warning',
+		'dead_letter_warning', 'tls_ca_file', 'tls_client_cert',
+		'tls_client_key'
+	);
+	$overrides = array();
+
+	foreach ($names as $name) {
+		$setting = 'audit_syslog_' . $name;
+		$overrides[$name] = isset($post[$setting]) && is_scalar($post[$setting])
+			? (string) $post[$setting]
+			: '';
+	}
+
+	$config = audit_syslog_config($overrides);
+	$enabling = isset($post['audit_syslog_enabled']) && $post['audit_syslog_enabled'] === 'on';
+	$configuring = trim($overrides['receiver']) !== '';
+
+	if (($enabling || $configuring) && !$config['valid']) {
+		audit_record_event('audit.syslog.configuration.rejected', array(
+			'event_category' => 'audit',
+			'severity' => 'warning',
+			'action' => 'save',
+			'target_type' => 'syslog_configuration',
+			'operation_outcome' => 'failure',
+			'outcome_reason' => 'configuration_invalid',
+			'details' => array('errors' => $config['errors'])
+		));
+
+		raise_message(
+			'audit_syslog_configuration',
+			__('Remote Syslog settings were not saved: %s', implode(', ', $config['errors']), 'audit'),
+			MESSAGE_LEVEL_ERROR
+		);
+		header('Location: settings.php?tab=audit');
+		exit;
+	}
+}
+
 
 
 function audit_config_insert() {
 	global $action, $config;
 
+	audit_enforce_syslog_settings_request();
+
 	if (audit_log_valid_event()) {
 		$started_at = microtime(true);
 		/* prepare post */
diff --git a/audit_syslog.php b/audit_syslog.php
new file mode 100644
index 0000000..1445bbe
--- /dev/null
+++ b/audit_syslog.php
@@ -0,0 +1,821 @@
+ $maximum) {
+		$errors[] = $name . '_out_of_range';
+		return $default;
+	}
+
+	return $value;
+}
+
+function audit_syslog_valid_receiver($receiver) {
+	if ($receiver === '' || strlen($receiver) > 253 ||
+		preg_match('/[[:cntrl:][:space:]\\/@]/', $receiver) ||
+		strpos($receiver, '://') !== false) {
+		return false;
+	}
+
+	if (filter_var($receiver, FILTER_VALIDATE_IP) !== false) {
+		return true;
+	}
+
+	if (substr($receiver, -1) === '.') {
+		$receiver = substr($receiver, 0, -1);
+	}
+
+	if ($receiver === '' || strlen($receiver) > 253) {
+		return false;
+	}
+
+	$labels = explode('.', $receiver);
+	foreach ($labels as $label) {
+		if ($label === '' || strlen($label) > 63 ||
+			!preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i', $label)) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+function audit_syslog_valid_header_value($value, $maximum) {
+	return $value !== '' && strlen($value) <= $maximum &&
+		!preg_match('/[^\\x21-\\x7e]|[\\[\\]="]/', $value);
+}
+
+function audit_syslog_validate_optional_file($path, $name, &$errors) {
+	if ($path === '') {
+		return '';
+	}
+
+	if ($path[0] !== '/' || !is_file($path) || is_link($path) || !is_readable($path)) {
+		$errors[] = $name . '_invalid';
+	}
+
+	return $path;
+}
+
+function audit_syslog_config($overrides = array()) {
+	$defaults = array(
+		'receiver' => '',
+		'port' => '',
+		'transport' => 'udp',
+		'format' => 'json',
+		'facility' => 'local0',
+		'application' => 'cacti-audit',
+		'node_id' => php_uname('n'),
+		'timeout' => '5',
+		'udp_max_size' => '8192',
+		'retry_base' => '30',
+		'retry_max' => '3600',
+		'max_attempts' => '10',
+		'batch_size' => '100',
+		'pending_age_warning' => '900',
+		'dead_letter_warning' => '1',
+		'tls_ca_file' => '',
+		'tls_client_cert' => '',
+		'tls_client_key' => ''
+	);
+	$values = array();
+
+	foreach ($defaults as $name => $default) {
+		$setting = 'audit_syslog_' . $name;
+		$values[$name] = array_key_exists($name, $overrides)
+			? $overrides[$name]
+			: audit_syslog_read_setting($setting, $default);
+	}
+
+	$errors = array();
+	$receiver = trim((string) $values['receiver']);
+	if (!audit_syslog_valid_receiver($receiver)) {
+		$errors[] = 'receiver_invalid';
+	}
+
+	$transport = strtolower((string) $values['transport']);
+	if (!in_array($transport, array('udp', 'tcp', 'tls'), true)) {
+		$errors[] = 'transport_invalid';
+		$transport = 'udp';
+	}
+
+	$format = strtolower((string) $values['format']);
+	if (!in_array($format, array('rfc5424', 'cef', 'json'), true)) {
+		$errors[] = 'format_invalid';
+		$format = 'json';
+	}
+
+	$facility_map = audit_syslog_facilities();
+	$facility = strtolower((string) $values['facility']);
+	if (!isset($facility_map[$facility])) {
+		$errors[] = 'facility_invalid';
+		$facility = 'local0';
+	}
+
+	$application = trim((string) $values['application']);
+	if (!audit_syslog_valid_header_value($application, 48)) {
+		$errors[] = 'application_invalid';
+		$application = 'cacti-audit';
+	}
+
+	$node_id = trim((string) $values['node_id']);
+	if (!audit_syslog_valid_header_value($node_id, 255)) {
+		$errors[] = 'node_id_invalid';
+		$node_id = 'cacti';
+	}
+
+	$port = trim((string) $values['port']) === ''
+		? ($transport === 'tls' ? 6514 : 514)
+		: audit_syslog_bounded_integer($values['port'], $transport === 'tls' ? 6514 : 514, 1, 65535, $errors, 'port');
+	$timeout = audit_syslog_bounded_integer($values['timeout'], 5, 1, 30, $errors, 'timeout');
+	$udp_max_size = audit_syslog_bounded_integer($values['udp_max_size'], 8192, 512, 65507, $errors, 'udp_max_size');
+	$retry_base = audit_syslog_bounded_integer($values['retry_base'], 30, 1, 3600, $errors, 'retry_base');
+	$retry_max = audit_syslog_bounded_integer($values['retry_max'], 3600, 1, 86400, $errors, 'retry_max');
+	$max_attempts = audit_syslog_bounded_integer($values['max_attempts'], 10, 1, 100, $errors, 'max_attempts');
+	$batch_size = audit_syslog_bounded_integer($values['batch_size'], 100, 1, 1000, $errors, 'batch_size');
+	$pending_age_warning = audit_syslog_bounded_integer($values['pending_age_warning'], 900, 60, 604800, $errors, 'pending_age_warning');
+	$dead_letter_warning = audit_syslog_bounded_integer($values['dead_letter_warning'], 1, 1, 1000000, $errors, 'dead_letter_warning');
+
+	if ($retry_max < $retry_base) {
+		$errors[] = 'retry_max_less_than_base';
+		$retry_max = $retry_base;
+	}
+
+	$tls_ca_file = trim((string) $values['tls_ca_file']);
+	$tls_client_cert = trim((string) $values['tls_client_cert']);
+	$tls_client_key = trim((string) $values['tls_client_key']);
+
+	if ($transport === 'tls') {
+		audit_syslog_validate_optional_file($tls_ca_file, 'tls_ca_file', $errors);
+		audit_syslog_validate_optional_file($tls_client_cert, 'tls_client_cert', $errors);
+		audit_syslog_validate_optional_file($tls_client_key, 'tls_client_key', $errors);
+
+		if (($tls_client_cert === '') !== ($tls_client_key === '')) {
+			$errors[] = 'tls_client_identity_incomplete';
+		}
+	}
+
+	$config = array(
+		'receiver' => $receiver,
+		'port' => $port,
+		'transport' => $transport,
+		'format' => $format,
+		'facility' => $facility,
+		'application' => $application,
+		'node_id' => $node_id,
+		'timeout' => $timeout,
+		'udp_max_size' => $udp_max_size,
+		'retry_base' => $retry_base,
+		'retry_max' => $retry_max,
+		'max_attempts' => $max_attempts,
+		'batch_size' => $batch_size,
+		'pending_age_warning' => $pending_age_warning,
+		'dead_letter_warning' => $dead_letter_warning,
+		'tls_ca_file' => $tls_ca_file,
+		'tls_client_cert' => $tls_client_cert,
+		'tls_client_key' => $tls_client_key,
+		'poller_id' => defined('POLLER_ID') ? (string) POLLER_ID : '',
+		'tls_verify_peer' => true,
+		'tls_verify_peer_name' => true,
+		'errors' => array_values(array_unique($errors))
+	);
+	$config['valid'] = empty($config['errors']);
+	$config['fingerprint'] = audit_syslog_destination_fingerprint($config);
+
+	return $config;
+}
+
+function audit_syslog_destination_fingerprint($config) {
+	$identity = array(
+		'receiver' => $config['receiver'],
+		'port' => (int) $config['port'],
+		'transport' => $config['transport'],
+		'format' => $config['format'],
+		'facility' => $config['facility'],
+		'application' => $config['application'],
+		'node_id' => $config['node_id'],
+		'tls_ca_file' => $config['tls_ca_file'],
+		'tls_client_cert' => $config['tls_client_cert']
+	);
+
+	return hash('sha256', audit_json_encode($identity, JSON_UNESCAPED_SLASHES));
+}
+
+function audit_syslog_facilities() {
+	return array(
+		'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3,
+		'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7,
+		'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11,
+		'ntp' => 12, 'audit' => 13, 'alert' => 14, 'clock' => 15,
+		'local0' => 16, 'local1' => 17, 'local2' => 18, 'local3' => 19,
+		'local4' => 20, 'local5' => 21, 'local6' => 22, 'local7' => 23
+	);
+}
+
+function audit_syslog_severity_code($severity) {
+	$map = array(
+		'emergency' => 0, 'emerg' => 0, 'alert' => 1, 'critical' => 2,
+		'crit' => 2, 'error' => 3, 'err' => 3, 'warning' => 4,
+		'warn' => 4, 'notice' => 5, 'info' => 6, 'debug' => 7
+	);
+	$severity = strtolower((string) $severity);
+
+	return isset($map[$severity]) ? $map[$severity] : 6;
+}
+
+function audit_syslog_header_token($value, $maximum, $fallback) {
+	$value = preg_replace('/[^\\x21-\\x3c\\x3e-\\x5a\\x5e-\\x7e]/', '_', (string) $value);
+	$value = substr($value, 0, $maximum);
+
+	return $value === '' ? $fallback : $value;
+}
+
+function audit_syslog_structured_value($value) {
+	$value = preg_replace('/[\\x00-\\x1f\\x7f]/', ' ', (string) $value);
+
+	return str_replace(array('\\', '"', ']'), array('\\\\', '\\"', '\\]'), $value);
+}
+
+function audit_syslog_timestamp($value) {
+	$value = (string) $value;
+	if (preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]([0-9]{2}:[0-9]{2}:[0-9]{2})(\\.[0-9]{1,6})?/', $value, $matches)) {
+		return $matches[1] . 'T' . $matches[2] . (isset($matches[3]) ? $matches[3] : '') . 'Z';
+	}
+
+	return gmdate('Y-m-d\\TH:i:s\\Z');
+}
+
+function audit_syslog_normalized_data($event, $config) {
+	$data = audit_external_event_data($event);
+	$data['node_id'] = $config['node_id'];
+	$data['poller_id'] = $config['poller_id'] !== '' ? $config['poller_id'] : null;
+
+	return $data;
+}
+
+function audit_syslog_cef_escape_header($value) {
+	return str_replace(array('\\', '|', "\r", "\n"), array('\\\\', '\\|', ' ', ' '), (string) $value);
+}
+
+function audit_syslog_cef_escape_extension($value) {
+	return str_replace(
+		array('\\', '=', "\r", "\n"),
+		array('\\\\', '\\=', '\\r', '\\n'),
+		(string) $value
+	);
+}
+
+function audit_syslog_cef_severity($severity) {
+	$map = array(
+		'emergency' => 10, 'emerg' => 10, 'alert' => 10,
+		'critical' => 9, 'crit' => 9, 'error' => 8, 'err' => 8,
+		'warning' => 6, 'warn' => 6, 'notice' => 5,
+		'info' => 3, 'debug' => 1
+	);
+	$severity = strtolower((string) $severity);
+
+	return isset($map[$severity]) ? $map[$severity] : 3;
+}
+
+function audit_syslog_cef_payload($event, $config) {
+	$severity = audit_syslog_cef_severity($event['severity'] ?? 'info');
+	$header = array(
+		'CEF:0',
+		'Cacti',
+		'Audit Plugin',
+		'1.5',
+		$event['event_type'] ?? 'cacti.audit',
+		$event['action'] ?? 'audit',
+		$severity
+	);
+	$extension = array(
+		'externalId' => $event['event_uuid'] ?? '',
+		'suser' => $event['user_id'] ?? '',
+		'src' => $event['ip_address'] ?? '',
+		'act' => $event['action'] ?? '',
+		'outcome' => $event['operation_outcome'] ?? '',
+		'cs1Label' => 'Correlation ID',
+		'cs1' => $event['correlation_id'] ?? '',
+		'cs2Label' => 'Target',
+		'cs2' => trim(($event['target_type'] ?? '') . ':' . ($event['target_id'] ?? ''), ':'),
+		'cs3Label' => 'Node ID',
+		'cs3' => $config['node_id'],
+		'cn1Label' => 'Poller ID',
+		'cn1' => $config['poller_id']
+	);
+	$encoded_header = array_map('audit_syslog_cef_escape_header', $header);
+	$encoded_extension = array();
+
+	foreach ($extension as $name => $value) {
+		$encoded_extension[] = $name . '=' . audit_syslog_cef_escape_extension($value);
+	}
+
+	return implode('|', $encoded_header) . '|' . implode(' ', $encoded_extension);
+}
+
+function audit_syslog_message_payload($event, $config) {
+	if ($config['format'] === 'cef') {
+		return audit_syslog_cef_payload($event, $config);
+	}
+
+	if ($config['format'] === 'json') {
+		return audit_json_encode(audit_syslog_normalized_data($event, $config), JSON_UNESCAPED_SLASHES);
+	}
+
+	return 'Audit event ' . (string) ($event['event_uuid'] ?? '');
+}
+
+function audit_syslog_record($event, $config) {
+	if (empty($config['valid'])) {
+		return array(
+			'status' => 'failed',
+			'permanent' => true,
+			'error_code' => 'configuration_invalid',
+			'error' => implode(',', $config['errors']),
+			'record' => ''
+		);
+	}
+
+	$facilities = audit_syslog_facilities();
+	$priority = ($facilities[$config['facility']] * 8) +
+		audit_syslog_severity_code($event['severity'] ?? 'info');
+	$timestamp = audit_syslog_timestamp($event['event_time'] ?? '');
+	$hostname = audit_syslog_header_token($config['node_id'], 255, 'cacti');
+	$application = audit_syslog_header_token($config['application'], 48, 'cacti-audit');
+	$poller_id = $config['poller_id'] !== '' ? $config['poller_id'] : '-';
+	$process = audit_syslog_header_token($poller_id, 128, '-');
+	$message_id = audit_syslog_header_token($event['event_type'] ?? 'cacti.audit', 32, 'cacti.audit');
+	$structured_values = array(
+		'eventUuid' => $event['event_uuid'] ?? '',
+		'correlationId' => $event['correlation_id'] ?? '',
+		'nodeId' => $config['node_id'],
+		'pollerId' => $config['poller_id'],
+		'outcome' => $event['operation_outcome'] ?? '',
+		'targetType' => $event['target_type'] ?? '',
+		'targetId' => $event['target_id'] ?? '',
+		'integrityHash' => $event['integrity_hash'] ?? ''
+	);
+	$structured = array();
+
+	foreach ($structured_values as $name => $value) {
+		$structured[] = $name . '="' . audit_syslog_structured_value($value) . '"';
+	}
+
+	$record = '<' . $priority . '>1 ' . $timestamp . ' ' . $hostname . ' ' .
+		$application . ' ' . $process . ' ' . $message_id .
+		' [cactiAudit@23925 ' . implode(' ', $structured) . '] ' .
+		audit_syslog_message_payload($event, $config);
+
+	if (strlen($record) > 262144) {
+		return array(
+			'status' => 'failed',
+			'permanent' => true,
+			'error_code' => 'message_too_large',
+			'error' => 'The formatted Syslog record exceeds 262144 bytes.',
+			'record' => ''
+		);
+	}
+
+	if ($config['transport'] === 'udp' && strlen($record) > $config['udp_max_size']) {
+		return array(
+			'status' => 'failed',
+			'permanent' => true,
+			'error_code' => 'udp_message_too_large',
+			'error' => 'The formatted Syslog record exceeds the configured UDP maximum.',
+			'record' => ''
+		);
+	}
+
+	return array(
+		'status' => 'ready',
+		'permanent' => false,
+		'error_code' => '',
+		'error' => '',
+		'record' => $record
+	);
+}
+
+function audit_syslog_frame($record, $transport) {
+	if ($transport === 'tcp' || $transport === 'tls') {
+		return strlen($record) . ' ' . $record;
+	}
+
+	return $record;
+}
+
+function audit_syslog_socket_target($config) {
+	$receiver = $config['receiver'];
+	if (filter_var($receiver, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
+		$receiver = '[' . $receiver . ']';
+	}
+
+	$scheme = $config['transport'] === 'tls' ? 'tls' : $config['transport'];
+
+	return $scheme . '://' . $receiver . ':' . $config['port'];
+}
+
+function audit_syslog_open_socket($config) {
+	$context_options = array();
+
+	if ($config['transport'] === 'tls') {
+		$context_options['ssl'] = array(
+			'verify_peer' => true,
+			'verify_peer_name' => true,
+			'allow_self_signed' => false,
+			'peer_name' => $config['receiver'],
+			'SNI_enabled' => true,
+			'disable_compression' => true
+		);
+
+		if ($config['tls_ca_file'] !== '') {
+			$context_options['ssl']['cafile'] = $config['tls_ca_file'];
+		}
+
+		if ($config['tls_client_cert'] !== '') {
+			$context_options['ssl']['local_cert'] = $config['tls_client_cert'];
+			$context_options['ssl']['local_pk'] = $config['tls_client_key'];
+		}
+	}
+
+	$context = stream_context_create($context_options);
+	$error_number = 0;
+	$error_message = '';
+	$flags = STREAM_CLIENT_CONNECT;
+	$socket = @stream_socket_client(
+		audit_syslog_socket_target($config),
+		$error_number,
+		$error_message,
+		$config['timeout'],
+		$flags,
+		$context
+	);
+
+	if ($socket === false) {
+		return array(
+			'socket' => null,
+			'error_code' => 'connection_failed',
+			'error' => audit_syslog_bounded_error($error_message !== '' ? $error_message : 'Unable to connect to Syslog receiver.')
+		);
+	}
+
+	stream_set_timeout($socket, $config['timeout']);
+	stream_set_blocking($socket, true);
+
+	return array('socket' => $socket, 'error_code' => '', 'error' => '');
+}
+
+function audit_syslog_bounded_error($error) {
+	$error = preg_replace('/[\\x00-\\x1f\\x7f]+/', ' ', (string) $error);
+
+	return substr(trim($error), 0, 1024);
+}
+
+function audit_syslog_write($socket, $message, $transport) {
+	if (!is_resource($socket)) {
+		return array('status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.');
+	}
+
+	if ($transport === 'udp') {
+		$written = @fwrite($socket, $message);
+		if ($written !== strlen($message)) {
+			return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => 'Unable to write the complete Syslog datagram.');
+		}
+	} else {
+		$length = strlen($message);
+		$offset = 0;
+
+		while ($offset < $length) {
+			$written = @fwrite($socket, substr($message, $offset));
+			if ($written === false || $written === 0) {
+				$metadata = stream_get_meta_data($socket);
+				$error = !empty($metadata['timed_out']) ? 'Syslog write timed out.' : 'Unable to write the complete Syslog record.';
+
+				return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => $error);
+			}
+
+			$offset += $written;
+		}
+	}
+
+	return array('status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '');
+}
+
+function audit_syslog_send_event($event, $config, &$socket = null) {
+	$formatted = audit_syslog_record($event, $config);
+	if ($formatted['status'] !== 'ready') {
+		return $formatted;
+	}
+
+	if (!is_resource($socket)) {
+		$opened = audit_syslog_open_socket($config);
+		if (!is_resource($opened['socket'])) {
+			return array(
+				'status' => 'failed',
+				'permanent' => false,
+				'error_code' => $opened['error_code'],
+				'error' => $opened['error']
+			);
+		}
+
+		$socket = $opened['socket'];
+	}
+
+	$message = audit_syslog_frame($formatted['record'], $config['transport']);
+	$result = audit_syslog_write($socket, $message, $config['transport']);
+	$result['permanent'] = false;
+
+	if ($result['status'] !== 'sent_unconfirmed' && is_resource($socket)) {
+		fclose($socket);
+		$socket = null;
+	}
+
+	return $result;
+}
+
+function audit_enqueue_syslog_event($audit_id) {
+	if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) {
+		return;
+	}
+
+	$event = db_fetch_row_prepared('SELECT id, event_uuid, request_status
+		FROM audit_log
+		WHERE id = ?',
+		array($audit_id));
+
+	if (!cacti_sizeof($event) || $event['request_status'] === 'started' || $event['event_uuid'] === '') {
+		return;
+	}
+
+	$config = audit_syslog_config();
+	$state = $config['valid'] ? 'pending' : 'dead_letter';
+	$error = $config['valid'] ? null : audit_syslog_bounded_error('configuration_invalid: ' . implode(',', $config['errors']));
+
+	db_execute_prepared('INSERT IGNORE INTO audit_syslog_delivery (
+			audit_id, event_uuid, destination_fingerprint, node_id, poller_id, state, attempts,
+			next_attempt, last_error, created_time, updated_time
+		) VALUES (?, ?, ?, ?, ?, ?, 0, UTC_TIMESTAMP(6), ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))',
+		array(
+			$event['id'], $event['event_uuid'], $config['fingerprint'],
+			$config['node_id'], $config['poller_id'], $state, $error
+		));
+}
+
+function audit_syslog_delivery_config($config, $delivery) {
+	if (isset($delivery['delivery_node_id']) && $delivery['delivery_node_id'] !== '') {
+		$config['node_id'] = $delivery['delivery_node_id'];
+	}
+
+	if (array_key_exists('delivery_poller_id', $delivery)) {
+		$config['poller_id'] = (string) $delivery['delivery_poller_id'];
+	}
+
+	$config['fingerprint'] = audit_syslog_destination_fingerprint($config);
+
+	return $config;
+}
+
+function audit_syslog_retry_delay($attempt, $config) {
+	$exponent = min(max(0, (int) $attempt - 1), 30);
+	$delay = $config['retry_base'] * pow(2, $exponent);
+
+	return (int) min($config['retry_max'], $delay);
+}
+
+function audit_syslog_update_delivery($delivery, $result, $config) {
+	$attempts = (int) $delivery['attempts'] + 1;
+	$error = isset($result['error']) ? audit_syslog_bounded_error($result['error']) : '';
+
+	if ($result['status'] === 'sent_unconfirmed') {
+		db_execute_prepared("UPDATE audit_syslog_delivery
+			SET state = 'sent_unconfirmed',
+				destination_fingerprint = ?,
+				attempts = ?,
+				last_attempt = UTC_TIMESTAMP(6),
+				sent_time = UTC_TIMESTAMP(6),
+				last_error = NULL,
+				updated_time = UTC_TIMESTAMP(6)
+			WHERE id = ?",
+			array($config['fingerprint'], $attempts, $delivery['delivery_id']));
+		return;
+	}
+
+	$permanent = !empty($result['permanent']) || $attempts >= $config['max_attempts'];
+	$state = $permanent ? 'dead_letter' : 'retry';
+	$delay = $permanent ? 0 : audit_syslog_retry_delay($attempts, $config);
+	$error_code = isset($result['error_code']) ? $result['error_code'] : 'delivery_failed';
+	$stored_error = audit_syslog_bounded_error($error_code . ': ' . $error);
+
+	db_execute_prepared('UPDATE audit_syslog_delivery
+		SET state = ?,
+			destination_fingerprint = ?,
+			attempts = ?,
+			next_attempt = CASE WHEN ? = 0 THEN next_attempt ELSE DATE_ADD(UTC_TIMESTAMP(6), INTERVAL ? SECOND) END,
+			last_attempt = UTC_TIMESTAMP(6),
+			last_error = ?,
+			updated_time = UTC_TIMESTAMP(6)
+		WHERE id = ?',
+		array($state, $config['fingerprint'], $attempts, $delay, $delay, $stored_error, $delivery['delivery_id']));
+}
+
+function audit_process_syslog_queue() {
+	if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) {
+		return;
+	}
+
+	$config = audit_syslog_config();
+	if (!$config['valid']) {
+		audit_syslog_check_health($config);
+		return;
+	}
+
+	$batch_size = (int) $config['batch_size'];
+	$deliveries = db_fetch_assoc_prepared("SELECT
+			d.id AS delivery_id, d.attempts, d.event_uuid AS delivery_event_uuid,
+			d.node_id AS delivery_node_id, d.poller_id AS delivery_poller_id,
+			a.*
+		FROM audit_syslog_delivery AS d
+		INNER JOIN audit_log AS a
+		ON a.id = d.audit_id
+		WHERE d.state IN ('pending', 'retry')
+		AND d.next_attempt <= UTC_TIMESTAMP(6)
+		AND a.request_status <> 'started'
+		ORDER BY d.next_attempt, d.id
+		LIMIT " . $batch_size,
+		array());
+	$socket = null;
+
+	foreach ($deliveries as $delivery) {
+		$delivery_config = audit_syslog_delivery_config($config, $delivery);
+		$result = audit_syslog_send_event($delivery, $delivery_config, $socket);
+		audit_syslog_update_delivery($delivery, $result, $delivery_config);
+
+		if ($result['status'] !== 'sent_unconfirmed' && empty($result['permanent'])) {
+			break;
+		}
+	}
+
+	if (is_resource($socket)) {
+		fclose($socket);
+	}
+
+	audit_syslog_check_health($config);
+}
+
+function audit_syslog_health() {
+	if (!db_table_exists('audit_syslog_delivery')) {
+		return array(
+			'pending' => 0, 'retry' => 0, 'sent_unconfirmed' => 0,
+			'dead_letter' => 0, 'oldest_pending_seconds' => 0,
+			'last_attempt' => null, 'last_sent' => null, 'last_error' => null
+		);
+	}
+
+	$row = db_fetch_row("SELECT
+			SUM(state = 'pending') AS pending,
+			SUM(state = 'retry') AS retry,
+			SUM(state = 'sent_unconfirmed') AS sent_unconfirmed,
+			SUM(state = 'dead_letter') AS dead_letter,
+			COALESCE(MAX(CASE WHEN state IN ('pending', 'retry')
+				THEN TIMESTAMPDIFF(SECOND, created_time, UTC_TIMESTAMP(6)) ELSE 0 END), 0) AS oldest_pending_seconds,
+			MAX(last_attempt) AS last_attempt,
+			MAX(sent_time) AS last_sent
+		FROM audit_syslog_delivery");
+	$last_error = db_fetch_cell("SELECT last_error
+		FROM audit_syslog_delivery
+		WHERE last_error IS NOT NULL
+		AND last_error <> ''
+		ORDER BY last_attempt DESC, id DESC
+		LIMIT 1");
+
+	return array(
+		'pending' => (int) ($row['pending'] ?? 0),
+		'retry' => (int) ($row['retry'] ?? 0),
+		'sent_unconfirmed' => (int) ($row['sent_unconfirmed'] ?? 0),
+		'dead_letter' => (int) ($row['dead_letter'] ?? 0),
+		'oldest_pending_seconds' => (int) ($row['oldest_pending_seconds'] ?? 0),
+		'last_attempt' => $row['last_attempt'] ?? null,
+		'last_sent' => $row['last_sent'] ?? null,
+		'last_error' => $last_error !== false && $last_error !== '' ? $last_error : null
+	);
+}
+
+function audit_syslog_check_health($config = null) {
+	if (!audit_syslog_enabled()) {
+		return;
+	}
+
+	$config = $config === null ? audit_syslog_config() : $config;
+	$health = audit_syslog_health();
+	$unhealthy = !$config['valid'] ||
+		$health['dead_letter'] >= $config['dead_letter_warning'] ||
+		$health['oldest_pending_seconds'] >= $config['pending_age_warning'];
+	$state = $unhealthy ? 'unhealthy' : 'healthy';
+	$previous = read_config_option('audit_syslog_health_state');
+
+	if ($previous !== $state) {
+		$message = $unhealthy
+			? 'WARNING: Audit Syslog delivery is unhealthy.'
+			: 'NOTICE: Audit Syslog delivery has recovered.';
+		cacti_log($message, false, 'AUDIT');
+		set_config_option('audit_syslog_health_state', $state);
+	}
+}
+
+function audit_syslog_retry_dead_letters($delivery_ids = array()) {
+	if (!db_table_exists('audit_syslog_delivery')) {
+		return 0;
+	}
+
+	$normalized_ids = array();
+	foreach ($delivery_ids as $delivery_id) {
+		$delivery_id = (int) $delivery_id;
+		if ($delivery_id > 0) {
+			$normalized_ids[] = $delivery_id;
+		}
+	}
+	$delivery_ids = array_values(array_unique($normalized_ids));
+	if (cacti_sizeof($delivery_ids) > 1000) {
+		$delivery_ids = array_slice($delivery_ids, 0, 1000);
+	}
+
+	if (cacti_sizeof($delivery_ids)) {
+		$placeholders = implode(',', array_fill(0, cacti_sizeof($delivery_ids), '?'));
+		db_execute_prepared("UPDATE audit_syslog_delivery
+			SET state = 'pending',
+				attempts = 0,
+				next_attempt = UTC_TIMESTAMP(6),
+				last_error = NULL,
+				updated_time = UTC_TIMESTAMP(6)
+			WHERE state = 'dead_letter'
+			AND id IN ($placeholders)",
+			$delivery_ids);
+	} else {
+		db_execute("UPDATE audit_syslog_delivery
+			SET state = 'pending',
+				attempts = 0,
+				next_attempt = UTC_TIMESTAMP(6),
+				last_error = NULL,
+				updated_time = UTC_TIMESTAMP(6)
+			WHERE state = 'dead_letter'");
+	}
+
+	return db_affected_rows();
+}
+
+function audit_syslog_test_delivery() {
+	$config = audit_syslog_config();
+	$event = array(
+		'event_uuid' => audit_uuid_v4(),
+		'correlation_id' => audit_request_correlation_id(),
+		'event_type' => 'audit.syslog.test',
+		'event_category' => 'audit',
+		'severity' => 'notice',
+		'user_id' => $_SESSION['sess_user_id'] ?? 0,
+		'action' => 'test',
+		'request_status' => 'completed',
+		'operation_outcome' => 'success',
+		'target_type' => 'syslog_receiver',
+		'target_id' => $config['receiver'],
+		'ip_address' => function_exists('get_client_addr') ? get_client_addr() : '',
+		'event_time' => audit_utc_time(),
+		'details' => audit_json_encode(array('test' => true))
+	);
+	$socket = null;
+	$result = audit_syslog_send_event($event, $config, $socket);
+
+	if (is_resource($socket)) {
+		fclose($socket);
+	}
+
+	return $result;
+}
diff --git a/js/functions.js b/js/functions.js
index 41bf752..96b9a78 100644
--- a/js/functions.js
+++ b/js/functions.js
@@ -117,6 +117,22 @@ $(function() {
 		});
 	});
 
+	$('#syslog_test').click(function() {
+		loadPageUsingPost('audit.php?action=syslog_test&header=false', {
+			__csrf_magic: csrfMagicToken
+		});
+	});
+
+	$('#syslog_retry').click(function() {
+		if (!window.confirm($(this).data('confirm'))) {
+			return;
+		}
+
+		loadPageUsingPost('audit.php?action=syslog_retry&header=false', {
+			__csrf_magic: csrfMagicToken
+		});
+	});
+
 	$('#export').click(function() {
 		document.location = 'audit.php?action=export' +
 			'&filter='+encodeURIComponent($('#filter').val())+
diff --git a/openspec/changes/add-evidence-integrity-governance/.openspec.yaml b/openspec/changes/add-evidence-integrity-governance/.openspec.yaml
new file mode 100644
index 0000000..3a03821
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-25
diff --git a/openspec/changes/add-evidence-integrity-governance/README.md b/openspec/changes/add-evidence-integrity-governance/README.md
new file mode 100644
index 0000000..15ac3bc
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/README.md
@@ -0,0 +1,3 @@
+# add-evidence-integrity-governance
+
+Add append-only evidence delivery, cryptographic integrity, delivery health, retention governance, investigation filters, and integrity reporting.
diff --git a/openspec/changes/add-evidence-integrity-governance/design.md b/openspec/changes/add-evidence-integrity-governance/design.md
new file mode 100644
index 0000000..f12cf37
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/design.md
@@ -0,0 +1,199 @@
+## Context
+
+The plugin currently stores every audit event in `audit_log` and can append a
+finalized event to a local file. File delivery is attempted in the request
+shutdown path and retried by `poller_bottom`, using status columns on the audit
+row. This does not provide a remote trust boundary, retry scheduling, dead-letter
+handling, or reliable health reporting.
+
+The target Cacti branch is 1.2.x and the plugin must remain compatible with PHP
+7.4. Cacti installations commonly integrate with SIEM products through a file
+forwarder or Syslog. Supporting product-specific HTTP APIs would add credentials,
+dependencies, and acknowledgement semantics that are not needed for the intended
+deployment model.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Retain the existing local-file output.
+- Add remote Syslog over UDP, TCP, and TLS.
+- Support RFC 5424 records with CEF or JSON message payloads.
+- Add stable node and poller identity and retain event UUIDs for receiver-side
+  deduplication.
+- Move remote transmission out of web request processing.
+- Provide bounded exponential retry, dead-letter state, health visibility, and
+  Audit Log Admin controls.
+- Establish an integrity chain and verification report.
+- Add governance and investigation capabilities without weakening undelivered
+  evidence.
+
+**Non-Goals:**
+
+- Native Splunk HEC, Microsoft Sentinel API, or generic webhook adapters.
+- Shipping or administering a SIEM receiver.
+- Claiming that a successful Syslog send proves durable receiver storage.
+- Guaranteeing immutability against a database or host administrator without an
+  independently controlled receiver.
+- Supporting RELP in the first implementation.
+
+## Decisions
+
+### Keep file and Syslog configuration independent
+
+The existing file output remains an independent option. Remote Syslog has a
+separate enable control and settings group rather than a generic provider
+framework. This avoids unnecessary abstraction while allowing deployments to
+use the file, Syslog, or both.
+
+### Use a per-destination delivery table
+
+Remote delivery state will be stored in a new table keyed by audit event and
+destination type. The table will contain event UUID, destination, state,
+attempts, next-attempt time, last attempt, completion time, last error, and a
+destination fingerprint. This avoids overloading the existing file-delivery
+columns and allows retention logic to protect evidence with unfinished remote
+delivery.
+
+Initial remote states are:
+
+- `pending`: queued and never attempted.
+- `retry`: a transient local connection or write failure occurred.
+- `sent_unconfirmed`: the complete Syslog record was accepted by the local
+  socket API, but the receiver did not provide durable acknowledgement.
+- `dead_letter`: the maximum attempts were reached or a permanent formatting or
+  configuration error prevents delivery.
+
+No Syslog transport will be labelled `confirmed` or `delivered`, because standard
+Syslog has no application-level durable acknowledgement.
+
+### Queue remote delivery and process it from the poller
+
+Finalizing an audit event creates a pending delivery row. The Cacti poller sends
+due rows in bounded batches. Web and CLI requests do not open remote sockets.
+This prevents receiver latency or failure from extending administrative
+requests and provides one retry path.
+
+### Use standard Syslog framing
+
+UDP sends one record per datagram. TCP and TLS use RFC 6587 octet-counting
+framing so embedded spaces and structured payloads do not create ambiguous
+record boundaries.
+
+Every record uses an RFC 5424 header containing:
+
+- PRI derived from configured facility and event severity.
+- UTC RFC 3339 timestamp.
+- stable node identity as hostname.
+- `cacti-audit` as application name.
+- poller identifier as process ID when available.
+- normalized event type as message ID.
+- structured data containing event UUID, correlation ID, node ID, poller ID,
+  outcome, target identifiers, and integrity metadata.
+
+The message portion is selectable between CEF and compact JSON. An RFC 5424
+message-only format is also available for receivers that prefer structured data
+without a secondary envelope.
+
+### Treat UDP explicitly as unconfirmed
+
+A successful UDP socket write means only that the local operating system
+accepted the datagram. The state becomes `sent_unconfirmed`; it is not retried.
+Local socket failures are retried. Oversized UDP records are permanent errors
+and move directly to dead-letter rather than being silently truncated or split.
+TCP and TLS writes also become `sent_unconfirmed` after a complete framed write.
+
+### Secure TLS by default
+
+TLS defaults to port 6514, peer verification enabled, and hostname verification
+enabled. Administrators may configure a CA file and optional client certificate
+and key paths. Certificate and hostname verification cannot be disabled.
+
+Receiver values are validated as a hostname or IP plus a numeric port. Control
+characters, URI schemes, embedded credentials, and unbounded timeouts are
+rejected. Test actions require Audit Log Admin, POST, and CSRF validation and
+are themselves audited without recording secrets.
+
+### Use bounded exponential retry
+
+Transient failures use exponential backoff based on configurable base and
+maximum delays, capped attempts, and bounded batch size. Permanent errors such
+as invalid configuration, invalid TLS files, unsupported format, or UDP
+oversize go directly to dead-letter. A manual Audit Log Admin retry resets
+selected dead-letter rows to pending and records the action.
+
+### Separate delivery health from individual events
+
+The Audit UI will report pending, retry, sent-unconfirmed, and dead-letter
+counts; oldest pending age; last attempt; last successful socket send; and last
+error. The plugin logs state transitions into the Cacti log and displays an
+administrator warning when configured thresholds are exceeded. Email or
+third-party alert dispatch is outside the first Syslog slice.
+
+### Chain finalized event content and support external checkpoints
+
+Integrity records will cover all immutable audit content using HMAC-SHA-256 with
+a separately configured key. A chain links each event to a previous chain value
+within a node-specific sequence. Periodic checkpoint records are sent through
+the same Syslog destination so an independently retained receiver can detect
+later local rewriting or deletion.
+
+Concurrent writers will not calculate chain state independently. A serialized
+database operation will allocate the next node sequence and previous hash. Key
+identifiers, not keys, are stored with events so rotation can be verified.
+
+### Apply retention policy after delivery and legal-hold checks
+
+Retention will not delete events with pending, retry, or dead-letter deliveries,
+events under legal hold, or events whose required archive/checkpoint is
+incomplete. Retention classes will be explicit rather than one global age.
+Archival records contain counts, ranges, integrity roots, and policy identity.
+
+### Add indexed investigation fields and coalesce self-access
+
+Investigation filters operate on normalized indexed columns. Low-value audit
+list/detail views may be coalesced within a bounded actor/session/time window,
+but exports, purge attempts, configuration changes, integrity reports, delivery
+tests, and failures are always recorded individually.
+
+## Risks / Trade-offs
+
+- **Syslog lacks durable acknowledgement** → Use `sent_unconfirmed`, retain UUIDs
+  for receiver deduplication, and never describe a socket write as confirmed.
+- **UDP loss or fragmentation** → Warn administrators, enforce a configurable
+  message limit, reject oversized records, and recommend TCP/TLS.
+- **TCP connection churn** → Process bounded batches and reuse a connection
+  within one poller delivery cycle.
+- **Receiver outage grows the queue** → Apply backoff, batch limits, health
+  thresholds, and dead-letter visibility while protecting queued evidence from
+  retention.
+- **A compromised Cacti host can access the HMAC key** → External checkpoints
+  make historical rewriting detectable, but cannot prevent future forgery after
+  compromise; document this boundary.
+- **Hash-chain serialization can add database contention** → Allocate sequence
+  state in a short transaction and keep network I/O outside the transaction.
+- **CEF cannot represent every nested field naturally** → Preserve normalized
+  investigation fields in CEF and encode remaining bounded detail as an escaped
+  extension; JSON remains available for full structured payloads.
+- **Self-access coalescing can hide detail** → Limit coalescing to enumerated
+  low-risk read events and retain actor, first/last time, and occurrence count.
+
+## Migration Plan
+
+1. Add schema for delivery queue, integrity sequence/key identifiers, governance
+   metadata, and investigation indexes using idempotent migrations.
+2. Leave existing file delivery enabled and unchanged.
+3. Introduce Syslog settings disabled by default.
+4. Allow Audit Log Admin to save and test configuration before enabling it.
+5. Enabling Syslog affects newly finalized events; optional controlled backfill
+   can enqueue a bounded historical range.
+6. Rollback disables Syslog and stops queue processing without deleting queued
+   state or audit evidence. Schema is retained until plugin uninstall.
+
+## Resolved Defaults
+
+- The first Syslog release includes optional mutual-TLS client certificate and
+  key paths.
+- The default maximum UDP record size is 8192 bytes.
+- Integrity checkpoints will use configurable event-count and elapsed-time
+  thresholds when that later task group is implemented.
diff --git a/openspec/changes/add-evidence-integrity-governance/proposal.md b/openspec/changes/add-evidence-integrity-governance/proposal.md
new file mode 100644
index 0000000..15e4d1b
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/proposal.md
@@ -0,0 +1,57 @@
+## Why
+
+The audit plugin currently writes to its database and an optional local file, so
+audit evidence can be lost with the Cacti host and administrators have limited
+visibility into delivery failures, integrity, retention, and investigations.
+This change establishes remote evidence delivery and the governance controls
+needed for a defensible audit trail.
+
+## What Changes
+
+- Add remote Syslog delivery settings alongside the existing local-file output.
+- Support RFC 5424, CEF, and JSON syslog records over UDP, TCP, and TLS.
+- Add stable node and poller identity to remotely delivered events.
+- Track transport-specific delivery states without treating UDP transmission as
+  proof of receiver acceptance.
+- Add retry backoff, delivery-health reporting, dead-letter handling, and
+  administrator alerts.
+- Add signed records or an externally anchored integrity chain, plus a
+  verification report.
+- Add retention classes, legal hold, archival policies, and safeguards for
+  undelivered evidence.
+- Add investigation filters for event type, outcome, category, target, time,
+  correlation identifier, and node.
+- Coalesce repetitive low-value audit self-access events.
+- Keep SIEM-specific configuration outside the plugin; Splunk, Microsoft
+  Sentinel, and other systems can ingest the existing file or receive Syslog.
+
+## Capabilities
+
+### New Capabilities
+
+- `remote-syslog-delivery`: Syslog settings, RFC 5424/CEF/JSON formatting,
+  UDP/TCP/TLS transmission, delivery-state semantics, and node identity.
+- `delivery-operations`: Retry scheduling, health status, dead-letter handling,
+  administrative testing, and alerts for the Syslog delivery queue.
+- `evidence-integrity`: Signed or chained event integrity, external anchoring,
+  verification, and receiver deduplication identifiers.
+- `audit-governance`: Retention classes, legal holds, archives, and protection
+  of evidence that has not reached its required destination.
+- `audit-investigation`: Structured filtering, self-access event coalescing, and
+  integrity/delivery completeness reporting.
+
+### Modified Capabilities
+
+None. This repository does not yet contain baseline OpenSpec capability files.
+
+## Impact
+
+- Affects plugin settings, audit event schema, external-delivery functions,
+  poller retry processing, administration screens, audit-list filtering, CLI
+  reporting, documentation, and tests.
+- Introduces outbound network connections controlled by Audit Log Admin and
+  requires strict destination validation, TLS verification, bounded timeouts,
+  secret redaction, and CSRF-protected test actions.
+- Syslog delivery is the only remote transport. Integrity anchoring, governance,
+  and investigation work remain separately reviewable tasks on the same feature
+  branch.
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/audit-governance/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/audit-governance/spec.md
new file mode 100644
index 0000000..6fc1ad4
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/specs/audit-governance/spec.md
@@ -0,0 +1,38 @@
+## ADDED Requirements
+
+### Requirement: Events have explicit retention classes
+The plugin SHALL assign events to configurable retention classes with retention
+age, remote-delivery requirement, archive requirement, and purge eligibility.
+
+#### Scenario: Event reaches class retention age
+- **WHEN** the event is not held and all class prerequisites are complete
+- **THEN** retention may archive or delete it according to the class policy
+
+### Requirement: Legal hold prevents deletion
+Audit Log Admin SHALL be able to place and release legal holds with a reason,
+actor, time, and optional case identifier.
+
+#### Scenario: Retention processes a held event
+- **WHEN** an event or covered range is under legal hold
+- **THEN** retention and purge leave the event unchanged
+
+#### Scenario: Administrator releases a hold
+- **WHEN** an Audit Log Admin releases a hold
+- **THEN** the release is audited and subsequent retention evaluates the event normally
+
+### Requirement: Archives include integrity metadata
+An archive SHALL include event count, event and time ranges, node ranges,
+terminal integrity values, policy identity, creation time, and a manifest
+authentication value.
+
+#### Scenario: Archive verification succeeds
+- **WHEN** an archive manifest and its events are unchanged
+- **THEN** the verification command reports matching counts, ranges, and integrity values
+
+### Requirement: Purge obeys governance restrictions
+Purge MUST exclude legal-hold events and evidence with incomplete required
+delivery or archival prerequisites.
+
+#### Scenario: Purge selection contains protected evidence
+- **WHEN** Audit Log Admin requests a purge covering protected and eligible events
+- **THEN** only eligible events are deleted and the result reports protected and deleted counts
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/audit-investigation/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/audit-investigation/spec.md
new file mode 100644
index 0000000..e4f0050
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/specs/audit-investigation/spec.md
@@ -0,0 +1,43 @@
+## ADDED Requirements
+
+### Requirement: Investigators can filter normalized audit fields
+Audit Log User SHALL be able to filter by event type, operation outcome,
+category, target type and identifier, time range, correlation ID, node ID, and
+poller ID in addition to existing filters.
+
+#### Scenario: Multiple filters are applied
+- **WHEN** an Audit Log User selects a time range, failure outcome, and node
+- **THEN** the list and export contain only events satisfying every selected filter
+
+### Requirement: Filter input is bounded and safely queried
+The plugin MUST validate filter values, use prepared parameters, and bound time
+range and result size where applicable.
+
+#### Scenario: Invalid time or identifier filter is submitted
+- **WHEN** a filter fails validation
+- **THEN** the plugin rejects or normalizes it without constructing raw SQL
+
+### Requirement: Low-value self-access events can be coalesced
+The plugin SHALL coalesce only enumerated audit-list and detail-view events
+within a bounded actor, session, event target, and time window.
+
+#### Scenario: Repeated detail views occur in one window
+- **WHEN** the same actor repeatedly views the same audit event within the configured window
+- **THEN** one coalesced event records first time, last time, and occurrence count
+
+#### Scenario: High-value audit action occurs
+- **WHEN** a user exports, purges, changes settings, tests delivery, retries dead letters, or runs verification
+- **THEN** the plugin records the action individually and does not coalesce it
+
+### Requirement: Administrators can verify integrity and delivery completeness
+The plugin SHALL provide a read-only CLI report and Audit Log Admin view that
+verify event hashes, chain continuity, checkpoint coverage, queue state, and
+delivery completeness for a bounded range.
+
+#### Scenario: Verification finds no gaps
+- **WHEN** all events and delivery records in the selected range are consistent
+- **THEN** the report returns success with counts and terminal integrity values
+
+#### Scenario: Verification finds corruption or delivery gaps
+- **WHEN** a hash mismatch, sequence gap, missing checkpoint, or unfinished required delivery exists
+- **THEN** the report returns failure, identifies the affected range, and does not modify evidence
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md
new file mode 100644
index 0000000..837ae6b
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md
@@ -0,0 +1,50 @@
+## ADDED Requirements
+
+### Requirement: Remote events are queued outside request processing
+The plugin SHALL create a pending delivery row for each finalized event when
+remote Syslog is enabled and SHALL perform network transmission from poller
+processing rather than the originating web or CLI request.
+
+#### Scenario: Web request finalizes while receiver is unavailable
+- **WHEN** an audited web request finishes and the Syslog receiver is unavailable
+- **THEN** the request can complete while a pending delivery remains available for the poller
+
+### Requirement: Transient failures use bounded backoff
+The plugin SHALL schedule transient failures using exponential backoff with
+configurable base delay, maximum delay, maximum attempts, and batch size.
+
+#### Scenario: Connection attempt fails
+- **WHEN** TCP or TLS connection establishment fails transiently
+- **THEN** attempts increment, a bounded error is stored, and `next_attempt` is scheduled in the future
+
+#### Scenario: Retry is not yet due
+- **WHEN** the poller selects queued deliveries
+- **THEN** it excludes retry rows whose `next_attempt` is in the future
+
+### Requirement: Permanent and exhausted failures enter dead-letter
+The plugin SHALL move permanent failures and rows that reach maximum attempts to
+dead-letter state.
+
+#### Scenario: Maximum attempts reached
+- **WHEN** a transient failure occurs on the configured final attempt
+- **THEN** the row becomes `dead_letter` and is no longer retried automatically
+
+#### Scenario: Administrator retries dead-letter rows
+- **WHEN** an Audit Log Admin performs a CSRF-valid manual retry
+- **THEN** selected rows return to pending, their retry schedule is reset, and the action is audited
+
+### Requirement: Delivery health is visible
+The plugin SHALL show Audit Log Admin the queue counts by state, oldest pending
+age, last attempt, last successful socket send, and last bounded error.
+
+#### Scenario: Queue exceeds a health threshold
+- **WHEN** pending age or dead-letter count exceeds configured thresholds
+- **THEN** the Audit interface displays a persistent warning and the transition is written to the Cacti log
+
+### Requirement: Retention protects unfinished delivery
+The plugin MUST NOT delete an event that has pending, retry, or dead-letter
+remote delivery state when remote delivery is required by its retention class.
+
+#### Scenario: Retention encounters a failed delivery
+- **WHEN** an event is older than its normal retention age but has dead-letter delivery
+- **THEN** the event and its delivery metadata remain available
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/evidence-integrity/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/evidence-integrity/spec.md
new file mode 100644
index 0000000..a006caa
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/specs/evidence-integrity/spec.md
@@ -0,0 +1,47 @@
+## ADDED Requirements
+
+### Requirement: Finalized audit events are authenticated
+The plugin SHALL calculate an HMAC-SHA-256 value over a canonical representation
+of every immutable finalized event field and SHALL record the key identifier
+used without storing the key in the audit row.
+
+#### Scenario: Protected content changes
+- **WHEN** any protected event field is modified after finalization
+- **THEN** integrity verification reports a mismatch
+
+### Requirement: Integrity values form a node-specific chain
+The plugin SHALL assign a monotonically increasing node sequence and SHALL bind
+each finalized event to the preceding chain value for that node.
+
+#### Scenario: Event is removed from the middle of a chain
+- **WHEN** verification processes the remaining sequence
+- **THEN** it reports the missing sequence or previous-value mismatch
+
+#### Scenario: Concurrent events finalize
+- **WHEN** multiple requests finalize concurrently on one node
+- **THEN** sequence allocation produces one deterministic order without duplicate sequence numbers
+
+### Requirement: Integrity checkpoints are delivered remotely
+The plugin SHALL periodically create checkpoint records containing node ID,
+sequence range, terminal chain value, key ID, and checkpoint time and SHALL
+queue them for Syslog delivery.
+
+#### Scenario: Local history is rewritten after checkpoint delivery
+- **WHEN** a verifier compares local state with an independently retained checkpoint
+- **THEN** it reports the inconsistent range
+
+### Requirement: Key rotation preserves verification
+The plugin SHALL support active and historical key identifiers so records remain
+verifiable across key rotation.
+
+#### Scenario: Administrator activates a new key
+- **WHEN** a new integrity key becomes active
+- **THEN** new events use its key ID while historical events retain their original key IDs
+
+### Requirement: Receiver deduplication identity is stable
+The plugin SHALL reuse an event UUID for all transmissions of the same event and
+SHALL document UUID as the receiver deduplication key.
+
+#### Scenario: Receiver receives a duplicate
+- **WHEN** a retry produces another copy of an already observed event UUID
+- **THEN** the receiver can identify it as the same logical event
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/remote-syslog-delivery/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/remote-syslog-delivery/spec.md
new file mode 100644
index 0000000..72a7cc6
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/specs/remote-syslog-delivery/spec.md
@@ -0,0 +1,74 @@
+## ADDED Requirements
+
+### Requirement: Administrators can configure remote Syslog
+The plugin SHALL allow Audit Log Admin users to enable remote Syslog and
+configure receiver hostname or IP, port, transport, payload format, facility,
+application name, node identity, timeout, and message-size limit.
+
+#### Scenario: Save valid Syslog configuration
+- **WHEN** an Audit Log Admin submits a valid Syslog configuration
+- **THEN** the plugin stores the configuration and excludes sensitive values from audit event payloads
+
+#### Scenario: Reject invalid receiver configuration
+- **WHEN** an administrator submits a receiver containing a URI scheme, control characters, embedded credentials, an invalid port, or an excessive timeout
+- **THEN** the plugin rejects the configuration without attempting a network connection
+
+### Requirement: Syslog supports UDP TCP and TLS transports
+The plugin SHALL send Syslog using UDP, TCP, or TLS according to the configured
+transport and SHALL default TLS to port 6514 with peer and hostname verification.
+
+#### Scenario: UDP send succeeds locally
+- **WHEN** the complete datagram is accepted by the local UDP socket
+- **THEN** the delivery state becomes `sent_unconfirmed` and the plugin does not claim receiver acknowledgement
+
+#### Scenario: TCP send succeeds locally
+- **WHEN** a complete RFC 6587 framed record is written to the TCP socket
+- **THEN** the delivery state becomes `sent_unconfirmed`
+
+#### Scenario: TLS verification fails
+- **WHEN** the receiver certificate cannot be verified under the configured TLS policy
+- **THEN** the delivery attempt fails, records a bounded error, and becomes eligible for retry
+
+### Requirement: Syslog records use standardized formats
+The plugin SHALL emit an RFC 5424 header and SHALL support RFC 5424 structured
+data, CEF, or compact JSON as the message payload.
+
+#### Scenario: Format an RFC 5424 record
+- **WHEN** a finalized audit event is formatted for Syslog
+- **THEN** the record contains valid PRI, version, UTC timestamp, node identity, application name, poller identity, message ID, and escaped structured data
+
+#### Scenario: Format a CEF record
+- **WHEN** CEF is selected
+- **THEN** event UUID, actor, source IP, action, outcome, target, severity, node, and correlation ID are mapped to escaped CEF fields
+
+#### Scenario: Format a JSON record
+- **WHEN** JSON is selected
+- **THEN** the RFC 5424 message contains one compact JSON object with bounded normalized audit data
+
+### Requirement: UDP records are bounded without silent truncation
+The plugin MUST reject a UDP record that exceeds the configured maximum datagram
+size and MUST NOT truncate or split an audit event silently.
+
+#### Scenario: UDP record exceeds the maximum
+- **WHEN** a formatted UDP record is larger than the configured maximum
+- **THEN** the delivery enters dead-letter state with an `udp_message_too_large` error
+
+### Requirement: Remote delivery includes stable identity
+Every remote record SHALL include event UUID, correlation ID, stable node ID,
+and poller ID when available.
+
+#### Scenario: Receiver observes a retried event
+- **WHEN** the same event is transmitted more than once after a local failure
+- **THEN** every transmission uses the same event UUID and node identity
+
+### Requirement: Syslog test actions are privileged and audited
+Only Audit Log Admin SHALL be able to test the receiver, and test actions MUST
+use POST and CSRF protection.
+
+#### Scenario: Audit user attempts a delivery test
+- **WHEN** a user without Audit Log Admin invokes the test action
+- **THEN** the plugin returns HTTP 403 and sends no test record
+
+#### Scenario: Administrator sends a test record
+- **WHEN** an Audit Log Admin submits a CSRF-valid test request
+- **THEN** the plugin sends a clearly identified test event and audits the test result without storing secrets
diff --git a/openspec/changes/add-evidence-integrity-governance/tasks.md b/openspec/changes/add-evidence-integrity-governance/tasks.md
new file mode 100644
index 0000000..6db7ae0
--- /dev/null
+++ b/openspec/changes/add-evidence-integrity-governance/tasks.md
@@ -0,0 +1,72 @@
+## 1. Syslog Schema and Configuration
+
+- [x] 1.1 Add an idempotent Syslog delivery-queue schema with event UUID, destination fingerprint, state, attempt schedule, bounded error, and timestamps
+- [x] 1.2 Add a separate Remote Syslog settings group without changing the existing local-file settings
+- [x] 1.3 Add validated settings for receiver, port, UDP/TCP/TLS transport, RFC 5424/CEF/JSON payload, facility, application name, node identity, timeout, UDP size limit, retry limits, and batch size
+- [x] 1.4 Add TLS peer and hostname verification settings with optional CA and client-certificate paths, secure defaults, and secret-safe display and auditing
+- [x] 1.5 Restrict Syslog configuration and test controls to Audit Log Admin and enforce POST plus CSRF validation for state-changing actions
+
+## 2. Syslog Record Formatting
+
+- [x] 2.1 Implement RFC 5424 PRI, UTC timestamp, hostname, application, process, message ID, structured-data escaping, and normalized field mapping
+- [x] 2.2 Implement compact bounded JSON payload formatting with event UUID, correlation ID, actor, source, action, outcome, target, node, and poller identity
+- [x] 2.3 Implement CEF payload mapping and escaping for normalized audit fields
+- [x] 2.4 Implement RFC 6587 octet-count framing for TCP and TLS while keeping one complete record per UDP datagram
+- [x] 2.5 Reject oversized UDP records without truncation or splitting and classify them as permanent delivery errors
+- [x] 2.6 Add unit tests and fixtures for RFC 5424, JSON, CEF, escaping, framing, size limits, and stable event identity
+
+## 3. Syslog Queue and Transports
+
+- [x] 3.1 Enqueue one pending Syslog delivery row when an audit event is finalized and Syslog is enabled, without opening a network connection in the request
+- [x] 3.2 Implement bounded UDP writes and record successful local socket acceptance as sent_unconfirmed
+- [x] 3.3 Implement bounded TCP connection and complete framed writes with reusable connections within a poller batch
+- [x] 3.4 Implement TLS connection and framed writes with peer and hostname verification enabled by default
+- [x] 3.5 Process due queue rows from the Cacti poller in configurable bounded batches
+- [x] 3.6 Implement exponential retry scheduling for transient failures and dead-letter transitions for permanent or exhausted failures
+- [x] 3.7 Preserve event UUID and node identity across retries and prevent duplicate active queue rows for the same event and destination
+- [x] 3.8 Add transport tests using local UDP, TCP, and TLS receivers, including timeouts, partial writes, certificate failure, receiver outage, and duplicate processing
+
+## 4. Syslog Administration and Operations
+
+- [x] 4.1 Add an audited Audit Log Admin test action that emits a clearly identified Syslog test record without exposing secrets
+- [x] 4.2 Add queue-health reporting for state counts, oldest pending age, last attempt, last successful socket send, and last bounded error
+- [x] 4.3 Add configurable health thresholds, persistent Audit UI warnings, and Cacti log transitions for unhealthy and recovered states
+- [x] 4.4 Add an audited Audit Log Admin action to retry selected dead-letter rows
+- [x] 4.5 Ensure retention and purge do not remove events with unfinished required Syslog delivery
+- [x] 4.6 Document receiver setup, facility and format choices, UDP limitations, TCP/TLS recommendations, SIEM ingestion, and the sent_unconfirmed delivery semantics
+- [x] 4.7 Run PHP compatibility, security, migration, and regression tests and verify existing local-file delivery remains unchanged
+
+## 5. Evidence Integrity
+
+- [ ] 5.1 Add idempotent schema for node sequence, previous-chain value, event HMAC, key identifier, and integrity checkpoints
+- [ ] 5.2 Define and test a versioned canonical representation of immutable finalized audit fields
+- [ ] 5.3 Implement serialized node-specific sequence allocation and HMAC-SHA-256 chain calculation during finalization
+- [ ] 5.4 Add active and historical key identifiers with secure key-loading and rotation behavior that never stores keys in audit rows
+- [ ] 5.5 Create periodic count- and time-based checkpoint records and enqueue them through the Syslog queue
+- [ ] 5.6 Add integrity tests for mutation, deletion, sequence gaps, concurrent finalization, key rotation, and checkpoint comparison
+
+## 6. Governance and Archival
+
+- [ ] 6.1 Add retention-class schema and administration for age, required Syslog delivery, archive requirement, and purge eligibility
+- [ ] 6.2 Assign a retention class to each event with a safe default and migration behavior for existing events
+- [ ] 6.3 Add audited legal-hold placement and release with reason, actor, time, scope, and optional case identifier
+- [ ] 6.4 Add archival generation with event ranges, counts, node ranges, terminal integrity values, policy identity, and authenticated manifests
+- [ ] 6.5 Update scheduled retention and manual purge to enforce legal hold, delivery, archive, and checkpoint prerequisites and report protected counts
+- [ ] 6.6 Add governance tests for held ranges, mixed protected selections, archive verification, retention classes, and uninstall cleanup
+
+## 7. Investigation and Verification
+
+- [ ] 7.1 Add indexed normalized fields needed for event type, outcome, category, target, correlation ID, node ID, and poller ID filters
+- [ ] 7.2 Add bounded validated Audit Log User filters using safe parameterized queries for list and export
+- [ ] 7.3 Add configurable coalescing for enumerated low-risk audit list and detail reads, preserving first time, last time, actor, target, session, and occurrence count
+- [ ] 7.4 Exclude exports, purge, settings, delivery tests, dead-letter retries, verification, and all failures from coalescing
+- [ ] 7.5 Add a read-only CLI verification report for hashes, chain continuity, checkpoints, queue state, and delivery completeness over a bounded range
+- [ ] 7.6 Add an Audit Log Admin verification view with the same bounded checks and no evidence mutation
+- [ ] 7.7 Add investigation and verification tests for combined filters, invalid input, coalescing boundaries, corruption, chain gaps, and incomplete delivery
+
+## 8. Release Readiness
+
+- [ ] 8.1 Review all new tables and settings for install, upgrade, replication, and complete uninstall behavior
+- [ ] 8.2 Review permissions so Audit Log User remains read-only and all configuration, retry, legal-hold, purge, archive, and verification administration requires Audit Log Admin
+- [ ] 8.3 Update plugin version metadata, changelog, administrator documentation, and upgrade notes
+- [ ] 8.4 Run the full available test suite and perform a final security review of network destinations, TLS, error handling, secrets, CSRF, SQL, output encoding, and denial-of-service bounds
diff --git a/setup.php b/setup.php
index 4c1be32..7d74976 100644
--- a/setup.php
+++ b/setup.php
@@ -101,6 +101,7 @@ function audit_remove_deprecated_realms() {
 }
 
 function plugin_audit_uninstall() {
+	db_execute('DROP TABLE IF EXISTS audit_syslog_delivery');
 	db_execute('DROP TABLE IF EXISTS audit_log');
 	return true;
 }
@@ -160,6 +161,7 @@ function audit_check_upgrade() {
 		db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data");
 		db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status');
 		audit_upgrade_event_schema();
+		audit_setup_syslog_table();
 		audit_setup_realms();
 		audit_remove_deprecated_realms();
 
@@ -234,6 +236,7 @@ function audit_replicate_out($data) {
 
 function audit_poller_bottom() {
 	audit_retry_external_logs();
+	audit_process_syslog_queue();
 
 	$last_check = read_config_option('audit_last_check');
 	$now        = gmdate('Y-m-d');
@@ -244,7 +247,15 @@ function audit_poller_bottom() {
 		if ($retention > 0) {
 			$cutoff = audit_retention_cutoff($retention);
 
-			db_execute_prepared('DELETE FROM audit_log WHERE event_time < ?', array($cutoff->format('Y-m-d H:i:s')));
+			db_execute_prepared("DELETE FROM audit_log
+				WHERE event_time < ?
+				AND NOT EXISTS (
+					SELECT 1
+					FROM audit_syslog_delivery
+					WHERE audit_syslog_delivery.audit_id = audit_log.id
+					AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter')
+				)",
+				array($cutoff->format('Y-m-d H:i:s')));
 			$rows = db_affected_rows();
 			cacti_log('NOTE: Purged ' . $rows . ' Audit Log Records from Cacti', false, 'POLLER');
 		}
@@ -304,9 +315,46 @@ function audit_setup_table() {
 		ENGINE=InnoDB
 		COMMENT='Audit Log for all GUI activities'");
 
+	audit_setup_syslog_table();
+
 	return true;
 }
 
+function audit_setup_syslog_table() {
+	db_execute("CREATE TABLE IF NOT EXISTS `audit_syslog_delivery` (
+		`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+		`audit_id` bigint(20) unsigned NOT NULL,
+		`event_uuid` char(36) NOT NULL,
+		`destination_fingerprint` char(64) NOT NULL,
+		`node_id` varchar(255) NOT NULL,
+		`poller_id` varchar(64) DEFAULT NULL,
+		`state` varchar(20) NOT NULL DEFAULT 'pending',
+		`attempts` int unsigned NOT NULL DEFAULT 0,
+		`next_attempt` datetime(6) NOT NULL,
+		`last_attempt` datetime(6) DEFAULT NULL,
+		`sent_time` datetime(6) DEFAULT NULL,
+		`last_error` varchar(1024) DEFAULT NULL,
+		`created_time` datetime(6) NOT NULL,
+		`updated_time` datetime(6) NOT NULL,
+		PRIMARY KEY (`id`),
+		UNIQUE KEY `audit_destination` (`audit_id`, `destination_fingerprint`),
+		KEY `event_uuid` (`event_uuid`),
+		KEY `state_next_attempt` (`state`, `next_attempt`),
+		KEY `destination_state` (`destination_fingerprint`, `state`),
+		CONSTRAINT `fk_audit_syslog_event`
+			FOREIGN KEY (`audit_id`) REFERENCES `audit_log` (`id`)
+			ON DELETE CASCADE)
+		ENGINE=InnoDB
+		COMMENT='Remote Syslog delivery queue for audit events'");
+
+	db_execute("ALTER TABLE audit_syslog_delivery
+		ADD COLUMN IF NOT EXISTS node_id varchar(255) NOT NULL DEFAULT 'cacti'
+		AFTER destination_fingerprint");
+	db_execute("ALTER TABLE audit_syslog_delivery
+		ADD COLUMN IF NOT EXISTS poller_id varchar(64) DEFAULT NULL
+		AFTER node_id");
+}
+
 function audit_upgrade_event_schema($rcnn_id = false) {
 	$remote = $rcnn_id !== false;
 	$args   = $remote ? array(true, $rcnn_id) : array();
@@ -488,6 +536,182 @@ function audit_config_settings() {
 		),
 	);
 
+	if (php_sapi_name() === 'cli' || audit_user_is_admin()) {
+		$facility_options = array();
+		foreach (audit_syslog_facilities() as $facility => $code) {
+			$facility_options[$facility] = strtoupper($facility) . ' (' . $code . ')';
+		}
+
+		$syslog = array(
+			'audit_syslog_header' => array(
+				'friendly_name' => __('Remote Syslog', 'audit'),
+				'method' => 'spacer'
+			),
+			'audit_syslog_enabled' => array(
+				'friendly_name' => __('Enable Remote Syslog', 'audit'),
+				'description' => __('Queue finalized audit events for remote Syslog delivery. The existing external file output remains independent.', 'audit'),
+				'method' => 'checkbox',
+				'default' => 'off'
+			),
+			'audit_syslog_receiver' => array(
+				'friendly_name' => __('Syslog Receiver', 'audit'),
+				'description' => __('Enter a receiver hostname or IP address without a URI scheme, path, or credentials.', 'audit'),
+				'method' => 'textbox',
+				'default' => '',
+				'max_length' => '253',
+				'size' => '60'
+			),
+			'audit_syslog_port' => array(
+				'friendly_name' => __('Syslog Port', 'audit'),
+				'description' => __('Enter 1-65535, or leave blank to use 514 for UDP/TCP and 6514 for TLS.', 'audit'),
+				'method' => 'textbox',
+				'default' => '',
+				'max_length' => '5',
+				'size' => '8'
+			),
+			'audit_syslog_transport' => array(
+				'friendly_name' => __('Syslog Transport', 'audit'),
+				'description' => __('UDP sends one datagram without acknowledgement. TCP and TLS use RFC 6587 octet-count framing.', 'audit'),
+				'method' => 'drop_array',
+				'default' => 'udp',
+				'array' => array(
+					'udp' => __('UDP', 'audit'),
+					'tcp' => __('TCP', 'audit'),
+					'tls' => __('TLS', 'audit')
+				)
+			),
+			'audit_syslog_format' => array(
+				'friendly_name' => __('Syslog Payload Format', 'audit'),
+				'description' => __('All formats use an RFC 5424 header. Select RFC 5424 structured data, CEF, or compact JSON for the message.', 'audit'),
+				'method' => 'drop_array',
+				'default' => 'json',
+				'array' => array(
+					'rfc5424' => __('RFC 5424', 'audit'),
+					'cef' => __('CEF', 'audit'),
+					'json' => __('JSON', 'audit')
+				)
+			),
+			'audit_syslog_facility' => array(
+				'friendly_name' => __('Syslog Facility', 'audit'),
+				'description' => __('Select the facility used to calculate the RFC 5424 priority.', 'audit'),
+				'method' => 'drop_array',
+				'default' => 'local0',
+				'array' => $facility_options
+			),
+			'audit_syslog_application' => array(
+				'friendly_name' => __('Syslog Application Name', 'audit'),
+				'description' => __('RFC 5424 APP-NAME. Printable non-space ASCII, up to 48 characters.', 'audit'),
+				'method' => 'textbox',
+				'default' => 'cacti-audit',
+				'max_length' => '48',
+				'size' => '30'
+			),
+			'audit_syslog_node_id' => array(
+				'friendly_name' => __('Audit Node Identity', 'audit'),
+				'description' => __('Stable RFC 5424 hostname identity for this Cacti node. Do not use a value that changes on restart.', 'audit'),
+				'method' => 'textbox',
+				'default' => php_uname('n'),
+				'max_length' => '255',
+				'size' => '60'
+			),
+			'audit_syslog_timeout' => array(
+				'friendly_name' => __('Connection and Write Timeout', 'audit'),
+				'description' => __('Timeout in seconds, from 1 through 30.', 'audit'),
+				'method' => 'textbox',
+				'default' => '5',
+				'max_length' => '2',
+				'size' => '8'
+			),
+			'audit_syslog_udp_max_size' => array(
+				'friendly_name' => __('Maximum UDP Record Size', 'audit'),
+				'description' => __('Records larger than this byte limit are dead-lettered and are never truncated or split. TCP or TLS is recommended for large events.', 'audit'),
+				'method' => 'textbox',
+				'default' => '8192',
+				'max_length' => '5',
+				'size' => '10'
+			),
+			'audit_syslog_tls_header' => array(
+				'friendly_name' => __('Syslog TLS', 'audit'),
+				'method' => 'spacer'
+			),
+			'audit_syslog_tls_ca_file' => array(
+				'friendly_name' => __('TLS CA File', 'audit'),
+				'description' => __('Optional PEM CA bundle. Peer and hostname verification are always enabled.', 'audit'),
+				'method' => 'filepath',
+				'default' => '',
+				'max_length' => '255'
+			),
+			'audit_syslog_tls_client_cert' => array(
+				'friendly_name' => __('TLS Client Certificate', 'audit'),
+				'description' => __('Optional PEM client certificate. A client key must also be configured.', 'audit'),
+				'method' => 'filepath',
+				'default' => '',
+				'max_length' => '255'
+			),
+			'audit_syslog_tls_client_key' => array(
+				'friendly_name' => __('TLS Client Private Key', 'audit'),
+				'description' => __('Optional readable PEM private-key path. The key contents are never stored in audit events.', 'audit'),
+				'method' => 'filepath',
+				'default' => '',
+				'max_length' => '255'
+			),
+			'audit_syslog_delivery_header' => array(
+				'friendly_name' => __('Syslog Delivery Queue', 'audit'),
+				'method' => 'spacer'
+			),
+			'audit_syslog_retry_base' => array(
+				'friendly_name' => __('Retry Base Delay', 'audit'),
+				'description' => __('Initial retry delay in seconds, from 1 through 3600.', 'audit'),
+				'method' => 'textbox',
+				'default' => '30',
+				'max_length' => '4',
+				'size' => '8'
+			),
+			'audit_syslog_retry_max' => array(
+				'friendly_name' => __('Maximum Retry Delay', 'audit'),
+				'description' => __('Maximum retry delay in seconds, from the base delay through 86400.', 'audit'),
+				'method' => 'textbox',
+				'default' => '3600',
+				'max_length' => '5',
+				'size' => '8'
+			),
+			'audit_syslog_max_attempts' => array(
+				'friendly_name' => __('Maximum Delivery Attempts', 'audit'),
+				'description' => __('Move a record to dead-letter after this many attempts, from 1 through 100.', 'audit'),
+				'method' => 'textbox',
+				'default' => '10',
+				'max_length' => '3',
+				'size' => '8'
+			),
+			'audit_syslog_batch_size' => array(
+				'friendly_name' => __('Poller Batch Size', 'audit'),
+				'description' => __('Maximum due records processed per poller cycle, from 1 through 1000.', 'audit'),
+				'method' => 'textbox',
+				'default' => '100',
+				'max_length' => '4',
+				'size' => '8'
+			),
+			'audit_syslog_pending_age_warning' => array(
+				'friendly_name' => __('Pending Age Warning', 'audit'),
+				'description' => __('Show an unhealthy warning when the oldest queued record reaches this age in seconds.', 'audit'),
+				'method' => 'textbox',
+				'default' => '900',
+				'max_length' => '6',
+				'size' => '10'
+			),
+			'audit_syslog_dead_letter_warning' => array(
+				'friendly_name' => __('Dead-letter Warning Count', 'audit'),
+				'description' => __('Show an unhealthy warning when this many records are dead-lettered.', 'audit'),
+				'method' => 'textbox',
+				'default' => '1',
+				'max_length' => '7',
+				'size' => '10'
+			)
+		);
+
+		$temp = array_merge($temp, $syslog);
+	}
+
 	$tabs['audit'] = __('Audit', 'audit');
 
 	if (isset($settings['audit'])) {
diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php
index d8bb161..bd8d5e1 100644
--- a/tests/Security/Php74CompatibilityTest.php
+++ b/tests/Security/Php74CompatibilityTest.php
@@ -16,6 +16,7 @@
 	$files = array(
 		'audit.php',
 		'audit_functions.php',
+		'audit_syslog.php',
 		'setup.php',
 	);
 
diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php
index ba9f023..fbdc78d 100644
--- a/tests/Security/PreparedStatementConsistencyTest.php
+++ b/tests/Security/PreparedStatementConsistencyTest.php
@@ -17,6 +17,7 @@
 		$targetFiles = array(
 		'audit.php',
 		'audit_functions.php',
+		'audit_syslog.php',
 		'setup.php',
 		);
 
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 0aa22f2..b701e11 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -76,6 +76,18 @@ function db_column_exists($table, $column) {
 	}
 }
 
+if (!function_exists('db_table_exists')) {
+	function db_table_exists($table) {
+		return true;
+	}
+}
+
+if (!function_exists('db_affected_rows')) {
+	function db_affected_rows() {
+		return 0;
+	}
+}
+
 if (!function_exists('api_plugin_db_add_column')) {
 	function api_plugin_db_add_column($plugin, $table, $data) {
 		return true;
diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php
index 890b682..2f445ae 100644
--- a/tests/controller_security_test.php
+++ b/tests/controller_security_test.php
@@ -12,7 +12,11 @@
 	'html_escape($data',
 	"__('Outcome Reason:', 'audit')",
 	"header('Content-Type: text/csv; charset=UTF-8')",
-	"fputcsv("
+	"fputcsv(",
+	"case 'syslog_test':",
+	"case 'syslog_retry':",
+	'audit_syslog_test_delivery()',
+	'audit_syslog_retry_dead_letters($delivery_ids)'
 );
 
 foreach ($required_controller_guards as $guard) {
@@ -35,10 +39,14 @@
 	'ADD COLUMN IF NOT EXISTS external_error',
 	'SHOW CREATE TABLE $table',
 	'audit_retry_external_logs()',
+	'audit_process_syslog_queue()',
 	'logout_pre_session_destroy',
 	'event_uuid char(36)',
 	'operation_outcome',
-	'external_attempts'
+	'external_attempts',
+	'CREATE TABLE IF NOT EXISTS `audit_syslog_delivery`',
+	'DROP TABLE IF EXISTS audit_syslog_delivery',
+	"auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'))"
 );
 
 foreach ($required_schema_fragments as $fragment) {
@@ -77,6 +85,17 @@
 	exit(1);
 }
 
+if (substr_count($controller, 'csrf_check(false)') < 3) {
+	fwrite(STDERR, 'Purge, Syslog test, and Syslog retry actions must each validate CSRF.' . PHP_EOL);
+	exit(1);
+}
+
+if (strpos($functions, 'audit_enforce_syslog_settings_request()') === false ||
+	strpos($functions, "'audit.syslog.configuration.denied'") === false) {
+	fwrite(STDERR, 'Remote Syslog settings must enforce Audit Log Admin on save.' . PHP_EOL);
+	exit(1);
+}
+
 if (strpos($javascript, "loadPageNoHeader('audit.php?action=purge") !== false) {
 	fwrite(STDERR, 'Purge must not use the legacy GET request path.' . PHP_EOL);
 	exit(1);
@@ -87,4 +106,10 @@
 	exit(1);
 }
 
+if (strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_test") === false ||
+	strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_retry") === false) {
+	fwrite(STDERR, 'Syslog administration actions must use POST request paths.' . PHP_EOL);
+	exit(1);
+}
+
 print "Controller security checks passed.\n";
diff --git a/tests/syslog_functions_test.php b/tests/syslog_functions_test.php
new file mode 100644
index 0000000..01bf04f
--- /dev/null
+++ b/tests/syslog_functions_test.php
@@ -0,0 +1,420 @@
+ '127.0.0.1',
+		'port' => '514',
+		'transport' => 'udp',
+		'format' => 'json',
+		'facility' => 'local0',
+		'application' => 'cacti-audit',
+		'node_id' => 'cacti-node-1',
+		'timeout' => '1',
+		'udp_max_size' => '8192',
+		'retry_base' => '5',
+		'retry_max' => '60',
+		'max_attempts' => '5',
+		'batch_size' => '10',
+		'pending_age_warning' => '900',
+		'dead_letter_warning' => '1',
+		'tls_ca_file' => '',
+		'tls_client_cert' => '',
+		'tls_client_key' => ''
+	);
+
+	return audit_syslog_config(array_merge($values, $overrides));
+}
+
+function audit_syslog_test_event() {
+	return array(
+		'id' => 42,
+		'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca',
+		'correlation_id' => '48f486c3-90d0-45a2-a50d-3b31e571aa91',
+		'event_type' => 'cacti.user_admin.save',
+		'event_category' => 'identity_access',
+		'severity' => 'info',
+		'actor_type' => 'user',
+		'page' => 'user_admin.php',
+		'user_id' => 1,
+		'action' => 'save',
+		'request_status' => 'completed',
+		'operation_outcome' => 'success',
+		'target_type' => 'user',
+		'target_id' => '4',
+		'ip_address' => '192.0.2.10',
+		'event_time' => '2026-07-24 15:16:17.123456',
+		'details' => '{"test":true}',
+		'integrity_hash' => str_repeat('a', 64)
+	);
+}
+
+function audit_syslog_test_udp_server(&$port, &$error, &$error_message) {
+	for ($attempt = 0; $attempt < 20; $attempt++) {
+		$port = random_int(20000, 50000);
+		$server = @stream_socket_server(
+			'udp://127.0.0.1:' . $port,
+			$error,
+			$error_message,
+			STREAM_SERVER_BIND
+		);
+
+		if (is_resource($server)) {
+			return $server;
+		}
+	}
+
+	return false;
+}
+
+function audit_syslog_test_tls_material() {
+	$directory = sys_get_temp_dir() . '/audit-syslog-' . bin2hex(random_bytes(8));
+	if (!mkdir($directory, 0700)) {
+		return false;
+	}
+
+	$config_path = $directory . '/openssl.cnf';
+	$config_text = "[req]\n" .
+		"distinguished_name=req_dn\n" .
+		"req_extensions=v3_req\n" .
+		"prompt=no\n" .
+		"[req_dn]\n" .
+		"CN=127.0.0.1\n" .
+		"[v3_req]\n" .
+		"subjectAltName=IP:127.0.0.1\n" .
+		"keyUsage=digitalSignature,keyEncipherment\n" .
+		"extendedKeyUsage=serverAuth\n";
+	file_put_contents($config_path, $config_text);
+
+	$options = array(
+		'config' => $config_path,
+		'digest_alg' => 'sha256',
+		'private_key_bits' => 2048,
+		'private_key_type' => OPENSSL_KEYTYPE_RSA,
+		'req_extensions' => 'v3_req',
+		'x509_extensions' => 'v3_req'
+	);
+	$key = openssl_pkey_new($options);
+	$csr = $key === false ? false : openssl_csr_new(array('commonName' => '127.0.0.1'), $key, $options);
+	$certificate = $csr === false ? false : openssl_csr_sign($csr, null, $key, 1, $options);
+
+	if ($certificate === false ||
+		!openssl_x509_export($certificate, $certificate_pem) ||
+		!openssl_pkey_export($key, $key_pem, null, $options)) {
+		return false;
+	}
+
+	$ca_path = $directory . '/ca.pem';
+	$server_path = $directory . '/server.pem';
+	$result_path = $directory . '/received.log';
+	file_put_contents($ca_path, $certificate_pem);
+	file_put_contents($server_path, $certificate_pem . $key_pem);
+	chmod($server_path, 0600);
+
+	return array(
+		'directory' => $directory,
+		'config' => $config_path,
+		'ca' => $ca_path,
+		'server' => $server_path,
+		'result' => $result_path
+	);
+}
+
+function audit_syslog_test_tls_server($material, &$port) {
+	$context = stream_context_create(array(
+		'ssl' => array(
+			'local_cert' => $material['server'],
+			'verify_peer' => false,
+			'allow_self_signed' => true
+		)
+	));
+	$error = 0;
+	$error_message = '';
+	$server = stream_socket_server(
+		'tls://127.0.0.1:0',
+		$error,
+		$error_message,
+		STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
+		$context
+	);
+
+	if (!is_resource($server)) {
+		return false;
+	}
+
+	$name = stream_socket_get_name($server, false);
+	$port = (int) substr($name, strrpos($name, ':') + 1);
+
+	return $server;
+}
+
+function audit_syslog_test_remove_tls_material($material) {
+	foreach (array('result', 'server', 'ca', 'config') as $name) {
+		if (is_file($material[$name])) {
+			unlink($material[$name]);
+		}
+	}
+
+	rmdir($material['directory']);
+}
+
+$config = audit_syslog_test_config();
+audit_syslog_test_assert($config['valid'], 'A valid Syslog configuration must be accepted.');
+audit_syslog_test_assert($config['tls_verify_peer'] === true, 'TLS peer verification must always be enabled.');
+audit_syslog_test_assert($config['tls_verify_peer_name'] === true, 'TLS hostname verification must always be enabled.');
+audit_syslog_test_assert(
+	audit_syslog_test_config(array('transport' => 'udp', 'port' => ''))['port'] === 514,
+	'A blank UDP port must use the standard default of 514.'
+);
+audit_syslog_test_assert(
+	audit_syslog_test_config(array('transport' => 'tls', 'port' => ''))['port'] === 6514,
+	'A blank TLS port must use the standard default of 6514.'
+);
+
+$invalid = audit_syslog_test_config(array('receiver' => 'udp://user:secret@example.test'));
+audit_syslog_test_assert(!$invalid['valid'], 'Receiver URIs and embedded credentials must be rejected.');
+
+$event = audit_syslog_test_event();
+$formatted = audit_syslog_record($event, $config);
+audit_syslog_test_assert($formatted['status'] === 'ready', 'A valid event must produce a Syslog record.');
+audit_syslog_test_assert(strpos($formatted['record'], '<134>1 2026-07-24T15:16:17.123456Z cacti-node-1 cacti-audit 7 cacti.user_admin.save ') === 0,
+	'RFC 5424 headers must contain the calculated priority, UTC timestamp, node, app, poller, and message ID.');
+audit_syslog_test_assert(strpos($formatted['record'], 'eventUuid="32e0a97d-d9e8-4abc-8f41-2bbbc50793ca"') !== false,
+	'RFC 5424 structured data must contain the event UUID.');
+audit_syslog_test_assert(strpos($formatted['record'], '[cactiAudit@23925 ') !== false,
+	'RFC 5424 enterprise structured data must use the IANA-registered Cacti private enterprise number.');
+audit_syslog_test_assert(strpos($formatted['record'], '"node_id":"cacti-node-1"') !== false,
+	'JSON payloads must contain stable node identity.');
+
+$frame = audit_syslog_frame($formatted['record'], 'tcp');
+audit_syslog_test_assert($frame === strlen($formatted['record']) . ' ' . $formatted['record'],
+	'TCP and TLS must use RFC 6587 octet-count framing.');
+audit_syslog_test_assert(audit_syslog_frame($formatted['record'], 'udp') === $formatted['record'],
+	'UDP must send one unframed RFC 5424 record per datagram.');
+
+$cef_config = audit_syslog_test_config(array('format' => 'cef'));
+$cef = audit_syslog_record($event, $cef_config);
+audit_syslog_test_assert(strpos($cef['record'], 'CEF:0|Cacti|Audit Plugin|1.5|cacti.user_admin.save|save|3|') !== false,
+	'CEF payloads must contain normalized vendor, product, event, action, and severity fields.');
+audit_syslog_test_assert(strpos($cef['record'], 'externalId=32e0a97d-d9e8-4abc-8f41-2bbbc50793ca') !== false,
+	'CEF payloads must expose the stable event UUID for deduplication.');
+
+$rfc_config = audit_syslog_test_config(array('format' => 'rfc5424'));
+$rfc = audit_syslog_record($event, $rfc_config);
+audit_syslog_test_assert(strpos($rfc['record'], 'Audit event 32e0a97d-d9e8-4abc-8f41-2bbbc50793ca') !== false,
+	'RFC 5424-only payloads must remain identifiable.');
+
+$escaped_event = $event;
+$escaped_event['target_id'] = "value\\\"]\nnext";
+$escaped = audit_syslog_record($escaped_event, $config);
+audit_syslog_test_assert(strpos($escaped['record'], 'targetId="value\\\\\\"\\] next"') !== false,
+	'RFC 5424 structured data must escape backslashes, quotes, brackets, and control characters.');
+
+$small_udp = audit_syslog_test_config(array('udp_max_size' => '512'));
+$large_event = $event;
+$large_event['details'] = audit_json_encode(array('value' => str_repeat('x', 1024)));
+$oversized = audit_syslog_record($large_event, $small_udp);
+audit_syslog_test_assert($oversized['error_code'] === 'udp_message_too_large' && $oversized['permanent'],
+	'Oversized UDP events must fail permanently without truncation or splitting.');
+
+$again = audit_syslog_record($event, $config);
+audit_syslog_test_assert($formatted['record'] === $again['record'],
+	'Repeated formatting of one event must preserve receiver deduplication identity.');
+
+$udp_error = 0;
+$udp_error_message = '';
+$udp_port = 0;
+$udp_server = audit_syslog_test_udp_server($udp_port, $udp_error, $udp_error_message);
+audit_syslog_test_assert(is_resource($udp_server), 'The UDP integration-test receiver must start.');
+$udp_config = audit_syslog_test_config(array('port' => (string) $udp_port));
+$udp_socket = null;
+$udp_result = audit_syslog_send_event($event, $udp_config, $udp_socket);
+audit_syslog_test_assert($udp_result['status'] === 'sent_unconfirmed',
+	'A complete UDP socket write must be recorded as sent_unconfirmed.');
+stream_set_timeout($udp_server, 1);
+$udp_received = fread($udp_server, 65535);
+audit_syslog_test_assert($udp_received === audit_syslog_record($event, $udp_config)['record'],
+	'UDP transport must deliver exactly one complete untruncated record.');
+if (is_resource($udp_socket)) {
+	fclose($udp_socket);
+}
+fclose($udp_server);
+
+$tcp_error = 0;
+$tcp_error_message = '';
+$tcp_server = stream_socket_server('tcp://127.0.0.1:0', $tcp_error, $tcp_error_message);
+audit_syslog_test_assert(is_resource($tcp_server), 'The TCP integration-test receiver must start.');
+$tcp_name = stream_socket_get_name($tcp_server, false);
+$tcp_port = (int) substr($tcp_name, strrpos($tcp_name, ':') + 1);
+$tcp_config = audit_syslog_test_config(array('transport' => 'tcp', 'port' => (string) $tcp_port));
+$tcp_socket = null;
+$tcp_result = audit_syslog_send_event($event, $tcp_config, $tcp_socket);
+audit_syslog_test_assert($tcp_result['status'] === 'sent_unconfirmed',
+	'A complete TCP socket write must be recorded as sent_unconfirmed.');
+$tcp_peer = stream_socket_accept($tcp_server, 1);
+audit_syslog_test_assert(is_resource($tcp_peer), 'The TCP integration-test receiver must accept the connection.');
+stream_set_timeout($tcp_peer, 1);
+$tcp_received = fread($tcp_peer, 262144);
+$expected_tcp = audit_syslog_frame(audit_syslog_record($event, $tcp_config)['record'], 'tcp');
+audit_syslog_test_assert($tcp_received === $expected_tcp,
+	'TCP transport must deliver one complete RFC 6587 octet-counted record.');
+fclose($tcp_peer);
+if (is_resource($tcp_socket)) {
+	fclose($tcp_socket);
+}
+fclose($tcp_server);
+
+if (function_exists('stream_socket_pair') && function_exists('pcntl_fork') &&
+	extension_loaded('sockets') && function_exists('socket_import_stream')) {
+	$pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+	audit_syslog_test_assert(is_array($pair), 'The partial-write test must create a local stream pair.');
+	$native_socket = socket_import_stream($pair[0]);
+	socket_set_option($native_socket, SOL_SOCKET, SO_SNDBUF, 1024);
+	stream_set_timeout($pair[0], 2);
+	$partial_payload = str_repeat('p', 262144);
+	$partial_pid = pcntl_fork();
+	audit_syslog_test_assert($partial_pid >= 0, 'The partial-write test must fork a local reader.');
+
+	if ($partial_pid === 0) {
+		fclose($pair[0]);
+		$received_length = 0;
+		while ($received_length < strlen($partial_payload)) {
+			$chunk = fread($pair[1], 8192);
+			if ($chunk === false || $chunk === '') {
+				break;
+			}
+			$received_length += strlen($chunk);
+		}
+		fclose($pair[1]);
+		exit($received_length === strlen($partial_payload) ? 0 : 1);
+	}
+
+	fclose($pair[1]);
+	$partial_result = audit_syslog_write($pair[0], $partial_payload, 'tcp');
+	fclose($pair[0]);
+	pcntl_waitpid($partial_pid, $partial_status);
+	audit_syslog_test_assert($partial_result['status'] === 'sent_unconfirmed' &&
+		pcntl_wexitstatus($partial_status) === 0,
+		'TCP writes must loop until a record is complete when the socket accepts partial chunks.');
+
+	$timeout_pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+	$timeout_socket = socket_import_stream($timeout_pair[0]);
+	socket_set_option($timeout_socket, SOL_SOCKET, SO_SNDBUF, 1024);
+	stream_set_timeout($timeout_pair[0], 0, 100000);
+	$timeout_result = audit_syslog_write($timeout_pair[0], str_repeat('t', 4194304), 'tcp');
+	audit_syslog_test_assert($timeout_result['status'] === 'failed' &&
+		$timeout_result['error_code'] === 'write_failed',
+		'A blocked stream must fail with a bounded write timeout instead of hanging indefinitely.');
+	fclose($timeout_pair[0]);
+	fclose($timeout_pair[1]);
+}
+
+if (extension_loaded('openssl') && function_exists('pcntl_fork')) {
+	$tls_material = audit_syslog_test_tls_material();
+	audit_syslog_test_assert(is_array($tls_material), 'The TLS integration test must create temporary certificate material.');
+	$tls_port = 0;
+	$tls_server = audit_syslog_test_tls_server($tls_material, $tls_port);
+	audit_syslog_test_assert(is_resource($tls_server), 'The TLS integration-test receiver must start.');
+	$tls_pid = pcntl_fork();
+	audit_syslog_test_assert($tls_pid >= 0, 'The TLS integration test must fork a local receiver.');
+
+	if ($tls_pid === 0) {
+		$tls_peer = @stream_socket_accept($tls_server, 5);
+		if (is_resource($tls_peer)) {
+			stream_set_timeout($tls_peer, 2);
+			$tls_received = fread($tls_peer, 262144);
+			file_put_contents($tls_material['result'], $tls_received);
+			fclose($tls_peer);
+			exit(0);
+		}
+
+		exit(1);
+	}
+
+	fclose($tls_server);
+	$tls_config = audit_syslog_test_config(array(
+		'transport' => 'tls',
+		'port' => (string) $tls_port,
+		'tls_ca_file' => $tls_material['ca']
+	));
+	$tls_socket = null;
+	$tls_result = audit_syslog_send_event($event, $tls_config, $tls_socket);
+	if (is_resource($tls_socket)) {
+		fclose($tls_socket);
+	}
+	pcntl_waitpid($tls_pid, $tls_status);
+	audit_syslog_test_assert($tls_result['status'] === 'sent_unconfirmed',
+		'A verified TLS socket write must be recorded as sent_unconfirmed.');
+	audit_syslog_test_assert(pcntl_wexitstatus($tls_status) === 0,
+		'The verified TLS receiver must accept the connection.');
+	$tls_received = is_file($tls_material['result']) ? file_get_contents($tls_material['result']) : '';
+	$expected_tls = audit_syslog_frame(audit_syslog_record($event, $tls_config)['record'], 'tls');
+	audit_syslog_test_assert($tls_received === $expected_tls,
+		'TLS transport must deliver one complete RFC 6587 octet-counted record.');
+
+	$untrusted_port = 0;
+	$untrusted_server = audit_syslog_test_tls_server($tls_material, $untrusted_port);
+	audit_syslog_test_assert(is_resource($untrusted_server), 'The untrusted TLS test receiver must start.');
+	$untrusted_pid = pcntl_fork();
+	audit_syslog_test_assert($untrusted_pid >= 0, 'The untrusted TLS test must fork a local receiver.');
+
+	if ($untrusted_pid === 0) {
+		$untrusted_peer = @stream_socket_accept($untrusted_server, 5);
+		if (is_resource($untrusted_peer)) {
+			fclose($untrusted_peer);
+		}
+		exit(0);
+	}
+
+	fclose($untrusted_server);
+	$untrusted_config = audit_syslog_test_config(array(
+		'transport' => 'tls',
+		'port' => (string) $untrusted_port,
+		'tls_ca_file' => ''
+	));
+	$untrusted_socket = null;
+	$untrusted_result = audit_syslog_send_event($event, $untrusted_config, $untrusted_socket);
+	if (is_resource($untrusted_socket)) {
+		fclose($untrusted_socket);
+	}
+	pcntl_waitpid($untrusted_pid, $untrusted_status);
+	audit_syslog_test_assert($untrusted_result['status'] === 'failed' &&
+		$untrusted_result['error_code'] === 'connection_failed',
+		'TLS delivery must fail when the receiver certificate is not trusted.');
+
+	audit_syslog_test_remove_tls_material($tls_material);
+}
+
+$outage_error = 0;
+$outage_message = '';
+$outage_server = stream_socket_server('tcp://127.0.0.1:0', $outage_error, $outage_message);
+$outage_name = stream_socket_get_name($outage_server, false);
+$outage_port = (int) substr($outage_name, strrpos($outage_name, ':') + 1);
+fclose($outage_server);
+$outage_config = audit_syslog_test_config(array('transport' => 'tcp', 'port' => (string) $outage_port));
+$outage_socket = null;
+$outage = audit_syslog_send_event($event, $outage_config, $outage_socket);
+audit_syslog_test_assert($outage['status'] === 'failed' && $outage['error_code'] === 'connection_failed',
+	'An unavailable receiver must return a bounded transient connection failure.');
+
+$invalid_tls = audit_syslog_test_config(array(
+	'transport' => 'tls',
+	'port' => '6514',
+	'tls_ca_file' => '/does/not/exist'
+));
+audit_syslog_test_assert(!$invalid_tls['valid'] && in_array('tls_ca_file_invalid', $invalid_tls['errors'], true),
+	'An invalid TLS trust path must be rejected before a connection is attempted.');
+
+print "Syslog helper and transport tests passed.\n";
diff --git a/tests/syslog_queue_test.php b/tests/syslog_queue_test.php
new file mode 100644
index 0000000..775f161
--- /dev/null
+++ b/tests/syslog_queue_test.php
@@ -0,0 +1,155 @@
+ 'on',
+	'audit_syslog_receiver' => '127.0.0.1',
+	'audit_syslog_port' => '514',
+	'audit_syslog_transport' => 'udp',
+	'audit_syslog_format' => 'json',
+	'audit_syslog_facility' => 'local0',
+	'audit_syslog_application' => 'cacti-audit',
+	'audit_syslog_node_id' => 'queue-test-node',
+	'audit_syslog_timeout' => '5',
+	'audit_syslog_udp_max_size' => '8192',
+	'audit_syslog_retry_base' => '5',
+	'audit_syslog_retry_max' => '60',
+	'audit_syslog_max_attempts' => '5',
+	'audit_syslog_batch_size' => '10',
+	'audit_syslog_pending_age_warning' => '900',
+	'audit_syslog_dead_letter_warning' => '1',
+	'audit_syslog_tls_ca_file' => '',
+	'audit_syslog_tls_client_cert' => '',
+	'audit_syslog_tls_client_key' => ''
+);
+$audit_queue_calls = array();
+$audit_queue_affected_rows = 0;
+
+function read_config_option($name) {
+	global $audit_queue_settings;
+
+	return isset($audit_queue_settings[$name]) ? $audit_queue_settings[$name] : '';
+}
+
+function db_table_exists($table) {
+	return $table === 'audit_syslog_delivery';
+}
+
+function db_fetch_row_prepared($sql, $params = array()) {
+	return array(
+		'id' => (int) $params[0],
+		'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca',
+		'request_status' => 'completed'
+	);
+}
+
+function db_execute_prepared($sql, $params = array()) {
+	global $audit_queue_calls;
+
+	$audit_queue_calls[] = array('sql' => $sql, 'params' => $params);
+	return true;
+}
+
+function db_execute($sql) {
+	global $audit_queue_calls;
+
+	$audit_queue_calls[] = array('sql' => $sql, 'params' => array());
+	return true;
+}
+
+function db_affected_rows() {
+	global $audit_queue_affected_rows;
+
+	return $audit_queue_affected_rows;
+}
+
+function cacti_sizeof($value) {
+	return is_array($value) ? count($value) : 0;
+}
+
+require_once dirname(__DIR__) . '/audit_functions.php';
+
+function audit_queue_assert($condition, $message) {
+	if (!$condition) {
+		fwrite(STDERR, $message . PHP_EOL);
+		exit(1);
+	}
+}
+
+audit_enqueue_syslog_event(42);
+audit_queue_assert(count($audit_queue_calls) === 1, 'Finalization must enqueue one Syslog delivery row.');
+audit_queue_assert(strpos($audit_queue_calls[0]['sql'], 'INSERT IGNORE INTO audit_syslog_delivery') !== false,
+	'Queue insertion must be idempotent for one event and destination.');
+audit_queue_assert($audit_queue_calls[0]['params'][0] === 42, 'Queue insertion must retain the audit event ID.');
+audit_queue_assert($audit_queue_calls[0]['params'][1] === '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca',
+	'Queue insertion must retain the stable event UUID.');
+audit_queue_assert($audit_queue_calls[0]['params'][3] === 'queue-test-node',
+	'Queue insertion must snapshot the stable node identity.');
+audit_queue_assert($audit_queue_calls[0]['params'][5] === 'pending',
+	'A valid enabled destination must enqueue in pending state.');
+
+$config = audit_syslog_config();
+$retry_identity = audit_syslog_delivery_config($config, array(
+	'delivery_node_id' => 'original-node',
+	'delivery_poller_id' => '3'
+));
+audit_queue_assert($retry_identity['node_id'] === 'original-node' && $retry_identity['poller_id'] === '3',
+	'Retries must use the node and poller identity captured when the event was queued.');
+
+audit_queue_assert(audit_syslog_retry_delay(1, $config) === 5, 'The first retry must use the base delay.');
+audit_queue_assert(audit_syslog_retry_delay(2, $config) === 10, 'Retry delay must increase exponentially.');
+audit_queue_assert(audit_syslog_retry_delay(10, $config) === 60, 'Retry delay must be capped by the configured maximum.');
+
+$audit_queue_calls = array();
+$delivery = array('delivery_id' => 9, 'attempts' => 0);
+$failure = array(
+	'status' => 'failed',
+	'permanent' => false,
+	'error_code' => 'connection_failed',
+	'error' => "receiver unavailable\nwith control text"
+);
+audit_syslog_update_delivery($delivery, $failure, $config);
+audit_queue_assert($audit_queue_calls[0]['params'][0] === 'retry',
+	'A transient failure before maximum attempts must enter retry state.');
+audit_queue_assert($audit_queue_calls[0]['params'][2] === 1, 'A failed delivery must increment attempts.');
+audit_queue_assert($audit_queue_calls[0]['params'][3] === 5 && $audit_queue_calls[0]['params'][4] === 5,
+	'A transient failure must schedule the bounded exponential delay.');
+audit_queue_assert(strpos($audit_queue_calls[0]['params'][5], "\n") === false,
+	'Stored delivery errors must be bounded to one safe line.');
+
+$audit_queue_calls = array();
+$delivery['attempts'] = 4;
+audit_syslog_update_delivery($delivery, $failure, $config);
+audit_queue_assert($audit_queue_calls[0]['params'][0] === 'dead_letter',
+	'The configured final failed attempt must enter dead-letter state.');
+
+$audit_queue_calls = array();
+$permanent = $failure;
+$permanent['permanent'] = true;
+$permanent['error_code'] = 'udp_message_too_large';
+$delivery['attempts'] = 0;
+audit_syslog_update_delivery($delivery, $permanent, $config);
+audit_queue_assert($audit_queue_calls[0]['params'][0] === 'dead_letter',
+	'A permanent formatting failure must enter dead-letter immediately.');
+
+$audit_queue_calls = array();
+$success = array('status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '', 'permanent' => false);
+audit_syslog_update_delivery($delivery, $success, $config);
+audit_queue_assert(strpos($audit_queue_calls[0]['sql'], "state = 'sent_unconfirmed'") !== false,
+	'A complete socket write must enter sent_unconfirmed state.');
+
+$audit_queue_calls = array();
+$audit_queue_affected_rows = 2;
+$retried = audit_syslog_retry_dead_letters(array('2', 2, 0, -1, '7'));
+audit_queue_assert($retried === 2, 'Manual retry must report the affected row count.');
+audit_queue_assert($audit_queue_calls[0]['params'] === array(2, 7),
+	'Manual retry must accept only unique positive selected delivery IDs.');
+audit_queue_assert(strpos($audit_queue_calls[0]['sql'], "WHERE state = 'dead_letter'") !== false,
+	'Manual retry must never reset a non-dead-letter delivery.');
+
+$syslog_source = file_get_contents(dirname(__DIR__) . '/audit_syslog.php');
+audit_queue_assert(
+	strpos($syslog_source, "\$result['status'] !== 'sent_unconfirmed' && empty(\$result['permanent'])") !== false,
+	'Poller batches must stop after one transient receiver failure to bound outage latency.'
+);
+
+print "Syslog queue tests passed.\n";

From 0bee6bf92fd097a4f816044895cd8f0d6e16da78 Mon Sep 17 00:00:00 2001
From: Sean Mancini 
Date: Sat, 25 Jul 2026 09:41:23 -0400
Subject: [PATCH 19/36] Fix Syslog warning leakage in poller

---
 CHANGELOG.md                                  |  1 +
 audit_syslog.php                              | 68 ++++++++++++++++---
 .../specs/delivery-operations/spec.md         |  4 ++
 tests/syslog_functions_test.php               | 16 +++++
 4 files changed, 78 insertions(+), 11 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7946199..95088e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
 * feature: Queue remote delivery in the poller with exponential backoff, dead-letter handling, health reporting, and audited admin actions
 * security: Restrict Syslog settings, tests, and dead-letter retries to Audit Log Admin with validated destinations and CSRF-protected POST actions
 * security: Preserve unfinished Syslog evidence during scheduled retention and manual purge
+* issue: Capture expected Syslog connection and write warnings without leaking PHP notices into the Cacti poller log
 * feature: Verify user realm permission saves against the resulting database state
 * security: Group Audit Log User and Audit Log Admin permissions under Audit Plugin
 * feature: Add normalized compliance event identifiers, categories, actors, targets, outcomes, timing, and integrity metadata
diff --git a/audit_syslog.php b/audit_syslog.php
index 1445bbe..d20bf4f 100644
--- a/audit_syslog.php
+++ b/audit_syslog.php
@@ -444,6 +444,25 @@ function audit_syslog_socket_target($config) {
 	return $scheme . '://' . $receiver . ':' . $config['port'];
 }
 
+function audit_syslog_stream_operation($operation, &$warning = null) {
+	$warning = '';
+	$handler = function($severity, $message) use (&$warning) {
+		$warning = audit_syslog_bounded_error($message);
+		return true;
+	};
+
+	set_error_handler($handler);
+
+	try {
+		return call_user_func($operation);
+	} catch (Throwable $exception) {
+		$warning = audit_syslog_bounded_error($exception->getMessage());
+		return false;
+	} finally {
+		restore_error_handler();
+	}
+}
+
 function audit_syslog_open_socket($config) {
 	$context_options = array();
 
@@ -471,20 +490,33 @@ function audit_syslog_open_socket($config) {
 	$error_number = 0;
 	$error_message = '';
 	$flags = STREAM_CLIENT_CONNECT;
-	$socket = @stream_socket_client(
-		audit_syslog_socket_target($config),
-		$error_number,
-		$error_message,
-		$config['timeout'],
+	$stream_warning = '';
+	$socket = audit_syslog_stream_operation(function() use (
+		$config,
+		&$error_number,
+		&$error_message,
 		$flags,
 		$context
-	);
+	) {
+		return stream_socket_client(
+			audit_syslog_socket_target($config),
+			$error_number,
+			$error_message,
+			$config['timeout'],
+			$flags,
+			$context
+		);
+	}, $stream_warning);
 
 	if ($socket === false) {
+		$error = $error_message !== ''
+			? $error_message
+			: ($stream_warning !== '' ? $stream_warning : 'Unable to connect to Syslog receiver.');
+
 		return array(
 			'socket' => null,
 			'error_code' => 'connection_failed',
-			'error' => audit_syslog_bounded_error($error_message !== '' ? $error_message : 'Unable to connect to Syslog receiver.')
+			'error' => audit_syslog_bounded_error($error)
 		);
 	}
 
@@ -500,25 +532,39 @@ function audit_syslog_bounded_error($error) {
 	return substr(trim($error), 0, 1024);
 }
 
+function audit_syslog_fwrite($socket, $message, &$warning = null) {
+	return audit_syslog_stream_operation(function() use ($socket, $message) {
+		return fwrite($socket, $message);
+	}, $warning);
+}
+
 function audit_syslog_write($socket, $message, $transport) {
 	if (!is_resource($socket)) {
 		return array('status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.');
 	}
 
 	if ($transport === 'udp') {
-		$written = @fwrite($socket, $message);
+		$stream_warning = '';
+		$written = audit_syslog_fwrite($socket, $message, $stream_warning);
 		if ($written !== strlen($message)) {
-			return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => 'Unable to write the complete Syslog datagram.');
+			$error = $stream_warning !== ''
+				? $stream_warning
+				: 'Unable to write the complete Syslog datagram.';
+
+			return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => $error);
 		}
 	} else {
 		$length = strlen($message);
 		$offset = 0;
 
 		while ($offset < $length) {
-			$written = @fwrite($socket, substr($message, $offset));
+			$stream_warning = '';
+			$written = audit_syslog_fwrite($socket, substr($message, $offset), $stream_warning);
 			if ($written === false || $written === 0) {
 				$metadata = stream_get_meta_data($socket);
-				$error = !empty($metadata['timed_out']) ? 'Syslog write timed out.' : 'Unable to write the complete Syslog record.';
+				$error = !empty($metadata['timed_out'])
+					? 'Syslog write timed out.'
+					: ($stream_warning !== '' ? $stream_warning : 'Unable to write the complete Syslog record.');
 
 				return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => $error);
 			}
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md
index 837ae6b..d4c0757 100644
--- a/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md
+++ b/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md
@@ -17,6 +17,10 @@ configurable base delay, maximum delay, maximum attempts, and batch size.
 - **WHEN** TCP or TLS connection establishment fails transiently
 - **THEN** attempts increment, a bounded error is stored, and `next_attempt` is scheduled in the future
 
+#### Scenario: Socket operation emits an expected warning
+- **WHEN** a Syslog connection or write is refused, reset, or otherwise fails through a PHP stream warning
+- **THEN** the warning is captured as a bounded delivery error and is not forwarded to Cacti's global PHP error handler
+
 #### Scenario: Retry is not yet due
 - **WHEN** the poller selects queued deliveries
 - **THEN** it excludes retry rows whose `next_attempt` is in the future
diff --git a/tests/syslog_functions_test.php b/tests/syslog_functions_test.php
index 01bf04f..f23330f 100644
--- a/tests/syslog_functions_test.php
+++ b/tests/syslog_functions_test.php
@@ -183,6 +183,22 @@ function audit_syslog_test_remove_tls_material($material) {
 	'A blank TLS port must use the standard default of 6514.'
 );
 
+$outer_warning_called = false;
+$captured_warning = '';
+set_error_handler(function() use (&$outer_warning_called) {
+	$outer_warning_called = true;
+	return true;
+});
+$warning_result = audit_syslog_stream_operation(function() {
+	trigger_error("expected stream warning\nwith control text", E_USER_WARNING);
+	return false;
+}, $captured_warning);
+restore_error_handler();
+audit_syslog_test_assert($warning_result === false && !$outer_warning_called,
+	'Expected stream warnings must not leak into Cacti global error handling.');
+audit_syslog_test_assert($captured_warning === 'expected stream warning with control text',
+	'Captured stream warnings must be normalized into a bounded one-line delivery error.');
+
 $invalid = audit_syslog_test_config(array('receiver' => 'udp://user:secret@example.test'));
 audit_syslog_test_assert(!$invalid['valid'], 'Receiver URIs and embedded credentials must be rejected.');
 

From 973adce00caffe72b9877ec0921ff0630eb74904 Mon Sep 17 00:00:00 2001
From: Sean Mancini 
Date: Sat, 25 Jul 2026 09:44:18 -0400
Subject: [PATCH 20/36] Delete
 openspec/changes/add-evidence-integrity-governance directory

---
 .../.openspec.yaml                            |   2 -
 .../README.md                                 |   3 -
 .../design.md                                 | 199 ------------------
 .../proposal.md                               |  57 -----
 .../specs/audit-governance/spec.md            |  38 ----
 .../specs/audit-investigation/spec.md         |  43 ----
 .../specs/delivery-operations/spec.md         |  54 -----
 .../specs/evidence-integrity/spec.md          |  47 -----
 .../specs/remote-syslog-delivery/spec.md      |  74 -------
 .../tasks.md                                  |  72 -------
 10 files changed, 589 deletions(-)
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/.openspec.yaml
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/README.md
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/design.md
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/proposal.md
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/specs/audit-governance/spec.md
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/specs/audit-investigation/spec.md
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/specs/evidence-integrity/spec.md
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/specs/remote-syslog-delivery/spec.md
 delete mode 100644 openspec/changes/add-evidence-integrity-governance/tasks.md

diff --git a/openspec/changes/add-evidence-integrity-governance/.openspec.yaml b/openspec/changes/add-evidence-integrity-governance/.openspec.yaml
deleted file mode 100644
index 3a03821..0000000
--- a/openspec/changes/add-evidence-integrity-governance/.openspec.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-schema: spec-driven
-created: 2026-07-25
diff --git a/openspec/changes/add-evidence-integrity-governance/README.md b/openspec/changes/add-evidence-integrity-governance/README.md
deleted file mode 100644
index 15ac3bc..0000000
--- a/openspec/changes/add-evidence-integrity-governance/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# add-evidence-integrity-governance
-
-Add append-only evidence delivery, cryptographic integrity, delivery health, retention governance, investigation filters, and integrity reporting.
diff --git a/openspec/changes/add-evidence-integrity-governance/design.md b/openspec/changes/add-evidence-integrity-governance/design.md
deleted file mode 100644
index f12cf37..0000000
--- a/openspec/changes/add-evidence-integrity-governance/design.md
+++ /dev/null
@@ -1,199 +0,0 @@
-## Context
-
-The plugin currently stores every audit event in `audit_log` and can append a
-finalized event to a local file. File delivery is attempted in the request
-shutdown path and retried by `poller_bottom`, using status columns on the audit
-row. This does not provide a remote trust boundary, retry scheduling, dead-letter
-handling, or reliable health reporting.
-
-The target Cacti branch is 1.2.x and the plugin must remain compatible with PHP
-7.4. Cacti installations commonly integrate with SIEM products through a file
-forwarder or Syslog. Supporting product-specific HTTP APIs would add credentials,
-dependencies, and acknowledgement semantics that are not needed for the intended
-deployment model.
-
-## Goals / Non-Goals
-
-**Goals:**
-
-- Retain the existing local-file output.
-- Add remote Syslog over UDP, TCP, and TLS.
-- Support RFC 5424 records with CEF or JSON message payloads.
-- Add stable node and poller identity and retain event UUIDs for receiver-side
-  deduplication.
-- Move remote transmission out of web request processing.
-- Provide bounded exponential retry, dead-letter state, health visibility, and
-  Audit Log Admin controls.
-- Establish an integrity chain and verification report.
-- Add governance and investigation capabilities without weakening undelivered
-  evidence.
-
-**Non-Goals:**
-
-- Native Splunk HEC, Microsoft Sentinel API, or generic webhook adapters.
-- Shipping or administering a SIEM receiver.
-- Claiming that a successful Syslog send proves durable receiver storage.
-- Guaranteeing immutability against a database or host administrator without an
-  independently controlled receiver.
-- Supporting RELP in the first implementation.
-
-## Decisions
-
-### Keep file and Syslog configuration independent
-
-The existing file output remains an independent option. Remote Syslog has a
-separate enable control and settings group rather than a generic provider
-framework. This avoids unnecessary abstraction while allowing deployments to
-use the file, Syslog, or both.
-
-### Use a per-destination delivery table
-
-Remote delivery state will be stored in a new table keyed by audit event and
-destination type. The table will contain event UUID, destination, state,
-attempts, next-attempt time, last attempt, completion time, last error, and a
-destination fingerprint. This avoids overloading the existing file-delivery
-columns and allows retention logic to protect evidence with unfinished remote
-delivery.
-
-Initial remote states are:
-
-- `pending`: queued and never attempted.
-- `retry`: a transient local connection or write failure occurred.
-- `sent_unconfirmed`: the complete Syslog record was accepted by the local
-  socket API, but the receiver did not provide durable acknowledgement.
-- `dead_letter`: the maximum attempts were reached or a permanent formatting or
-  configuration error prevents delivery.
-
-No Syslog transport will be labelled `confirmed` or `delivered`, because standard
-Syslog has no application-level durable acknowledgement.
-
-### Queue remote delivery and process it from the poller
-
-Finalizing an audit event creates a pending delivery row. The Cacti poller sends
-due rows in bounded batches. Web and CLI requests do not open remote sockets.
-This prevents receiver latency or failure from extending administrative
-requests and provides one retry path.
-
-### Use standard Syslog framing
-
-UDP sends one record per datagram. TCP and TLS use RFC 6587 octet-counting
-framing so embedded spaces and structured payloads do not create ambiguous
-record boundaries.
-
-Every record uses an RFC 5424 header containing:
-
-- PRI derived from configured facility and event severity.
-- UTC RFC 3339 timestamp.
-- stable node identity as hostname.
-- `cacti-audit` as application name.
-- poller identifier as process ID when available.
-- normalized event type as message ID.
-- structured data containing event UUID, correlation ID, node ID, poller ID,
-  outcome, target identifiers, and integrity metadata.
-
-The message portion is selectable between CEF and compact JSON. An RFC 5424
-message-only format is also available for receivers that prefer structured data
-without a secondary envelope.
-
-### Treat UDP explicitly as unconfirmed
-
-A successful UDP socket write means only that the local operating system
-accepted the datagram. The state becomes `sent_unconfirmed`; it is not retried.
-Local socket failures are retried. Oversized UDP records are permanent errors
-and move directly to dead-letter rather than being silently truncated or split.
-TCP and TLS writes also become `sent_unconfirmed` after a complete framed write.
-
-### Secure TLS by default
-
-TLS defaults to port 6514, peer verification enabled, and hostname verification
-enabled. Administrators may configure a CA file and optional client certificate
-and key paths. Certificate and hostname verification cannot be disabled.
-
-Receiver values are validated as a hostname or IP plus a numeric port. Control
-characters, URI schemes, embedded credentials, and unbounded timeouts are
-rejected. Test actions require Audit Log Admin, POST, and CSRF validation and
-are themselves audited without recording secrets.
-
-### Use bounded exponential retry
-
-Transient failures use exponential backoff based on configurable base and
-maximum delays, capped attempts, and bounded batch size. Permanent errors such
-as invalid configuration, invalid TLS files, unsupported format, or UDP
-oversize go directly to dead-letter. A manual Audit Log Admin retry resets
-selected dead-letter rows to pending and records the action.
-
-### Separate delivery health from individual events
-
-The Audit UI will report pending, retry, sent-unconfirmed, and dead-letter
-counts; oldest pending age; last attempt; last successful socket send; and last
-error. The plugin logs state transitions into the Cacti log and displays an
-administrator warning when configured thresholds are exceeded. Email or
-third-party alert dispatch is outside the first Syslog slice.
-
-### Chain finalized event content and support external checkpoints
-
-Integrity records will cover all immutable audit content using HMAC-SHA-256 with
-a separately configured key. A chain links each event to a previous chain value
-within a node-specific sequence. Periodic checkpoint records are sent through
-the same Syslog destination so an independently retained receiver can detect
-later local rewriting or deletion.
-
-Concurrent writers will not calculate chain state independently. A serialized
-database operation will allocate the next node sequence and previous hash. Key
-identifiers, not keys, are stored with events so rotation can be verified.
-
-### Apply retention policy after delivery and legal-hold checks
-
-Retention will not delete events with pending, retry, or dead-letter deliveries,
-events under legal hold, or events whose required archive/checkpoint is
-incomplete. Retention classes will be explicit rather than one global age.
-Archival records contain counts, ranges, integrity roots, and policy identity.
-
-### Add indexed investigation fields and coalesce self-access
-
-Investigation filters operate on normalized indexed columns. Low-value audit
-list/detail views may be coalesced within a bounded actor/session/time window,
-but exports, purge attempts, configuration changes, integrity reports, delivery
-tests, and failures are always recorded individually.
-
-## Risks / Trade-offs
-
-- **Syslog lacks durable acknowledgement** → Use `sent_unconfirmed`, retain UUIDs
-  for receiver deduplication, and never describe a socket write as confirmed.
-- **UDP loss or fragmentation** → Warn administrators, enforce a configurable
-  message limit, reject oversized records, and recommend TCP/TLS.
-- **TCP connection churn** → Process bounded batches and reuse a connection
-  within one poller delivery cycle.
-- **Receiver outage grows the queue** → Apply backoff, batch limits, health
-  thresholds, and dead-letter visibility while protecting queued evidence from
-  retention.
-- **A compromised Cacti host can access the HMAC key** → External checkpoints
-  make historical rewriting detectable, but cannot prevent future forgery after
-  compromise; document this boundary.
-- **Hash-chain serialization can add database contention** → Allocate sequence
-  state in a short transaction and keep network I/O outside the transaction.
-- **CEF cannot represent every nested field naturally** → Preserve normalized
-  investigation fields in CEF and encode remaining bounded detail as an escaped
-  extension; JSON remains available for full structured payloads.
-- **Self-access coalescing can hide detail** → Limit coalescing to enumerated
-  low-risk read events and retain actor, first/last time, and occurrence count.
-
-## Migration Plan
-
-1. Add schema for delivery queue, integrity sequence/key identifiers, governance
-   metadata, and investigation indexes using idempotent migrations.
-2. Leave existing file delivery enabled and unchanged.
-3. Introduce Syslog settings disabled by default.
-4. Allow Audit Log Admin to save and test configuration before enabling it.
-5. Enabling Syslog affects newly finalized events; optional controlled backfill
-   can enqueue a bounded historical range.
-6. Rollback disables Syslog and stops queue processing without deleting queued
-   state or audit evidence. Schema is retained until plugin uninstall.
-
-## Resolved Defaults
-
-- The first Syslog release includes optional mutual-TLS client certificate and
-  key paths.
-- The default maximum UDP record size is 8192 bytes.
-- Integrity checkpoints will use configurable event-count and elapsed-time
-  thresholds when that later task group is implemented.
diff --git a/openspec/changes/add-evidence-integrity-governance/proposal.md b/openspec/changes/add-evidence-integrity-governance/proposal.md
deleted file mode 100644
index 15e4d1b..0000000
--- a/openspec/changes/add-evidence-integrity-governance/proposal.md
+++ /dev/null
@@ -1,57 +0,0 @@
-## Why
-
-The audit plugin currently writes to its database and an optional local file, so
-audit evidence can be lost with the Cacti host and administrators have limited
-visibility into delivery failures, integrity, retention, and investigations.
-This change establishes remote evidence delivery and the governance controls
-needed for a defensible audit trail.
-
-## What Changes
-
-- Add remote Syslog delivery settings alongside the existing local-file output.
-- Support RFC 5424, CEF, and JSON syslog records over UDP, TCP, and TLS.
-- Add stable node and poller identity to remotely delivered events.
-- Track transport-specific delivery states without treating UDP transmission as
-  proof of receiver acceptance.
-- Add retry backoff, delivery-health reporting, dead-letter handling, and
-  administrator alerts.
-- Add signed records or an externally anchored integrity chain, plus a
-  verification report.
-- Add retention classes, legal hold, archival policies, and safeguards for
-  undelivered evidence.
-- Add investigation filters for event type, outcome, category, target, time,
-  correlation identifier, and node.
-- Coalesce repetitive low-value audit self-access events.
-- Keep SIEM-specific configuration outside the plugin; Splunk, Microsoft
-  Sentinel, and other systems can ingest the existing file or receive Syslog.
-
-## Capabilities
-
-### New Capabilities
-
-- `remote-syslog-delivery`: Syslog settings, RFC 5424/CEF/JSON formatting,
-  UDP/TCP/TLS transmission, delivery-state semantics, and node identity.
-- `delivery-operations`: Retry scheduling, health status, dead-letter handling,
-  administrative testing, and alerts for the Syslog delivery queue.
-- `evidence-integrity`: Signed or chained event integrity, external anchoring,
-  verification, and receiver deduplication identifiers.
-- `audit-governance`: Retention classes, legal holds, archives, and protection
-  of evidence that has not reached its required destination.
-- `audit-investigation`: Structured filtering, self-access event coalescing, and
-  integrity/delivery completeness reporting.
-
-### Modified Capabilities
-
-None. This repository does not yet contain baseline OpenSpec capability files.
-
-## Impact
-
-- Affects plugin settings, audit event schema, external-delivery functions,
-  poller retry processing, administration screens, audit-list filtering, CLI
-  reporting, documentation, and tests.
-- Introduces outbound network connections controlled by Audit Log Admin and
-  requires strict destination validation, TLS verification, bounded timeouts,
-  secret redaction, and CSRF-protected test actions.
-- Syslog delivery is the only remote transport. Integrity anchoring, governance,
-  and investigation work remain separately reviewable tasks on the same feature
-  branch.
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/audit-governance/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/audit-governance/spec.md
deleted file mode 100644
index 6fc1ad4..0000000
--- a/openspec/changes/add-evidence-integrity-governance/specs/audit-governance/spec.md
+++ /dev/null
@@ -1,38 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Events have explicit retention classes
-The plugin SHALL assign events to configurable retention classes with retention
-age, remote-delivery requirement, archive requirement, and purge eligibility.
-
-#### Scenario: Event reaches class retention age
-- **WHEN** the event is not held and all class prerequisites are complete
-- **THEN** retention may archive or delete it according to the class policy
-
-### Requirement: Legal hold prevents deletion
-Audit Log Admin SHALL be able to place and release legal holds with a reason,
-actor, time, and optional case identifier.
-
-#### Scenario: Retention processes a held event
-- **WHEN** an event or covered range is under legal hold
-- **THEN** retention and purge leave the event unchanged
-
-#### Scenario: Administrator releases a hold
-- **WHEN** an Audit Log Admin releases a hold
-- **THEN** the release is audited and subsequent retention evaluates the event normally
-
-### Requirement: Archives include integrity metadata
-An archive SHALL include event count, event and time ranges, node ranges,
-terminal integrity values, policy identity, creation time, and a manifest
-authentication value.
-
-#### Scenario: Archive verification succeeds
-- **WHEN** an archive manifest and its events are unchanged
-- **THEN** the verification command reports matching counts, ranges, and integrity values
-
-### Requirement: Purge obeys governance restrictions
-Purge MUST exclude legal-hold events and evidence with incomplete required
-delivery or archival prerequisites.
-
-#### Scenario: Purge selection contains protected evidence
-- **WHEN** Audit Log Admin requests a purge covering protected and eligible events
-- **THEN** only eligible events are deleted and the result reports protected and deleted counts
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/audit-investigation/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/audit-investigation/spec.md
deleted file mode 100644
index e4f0050..0000000
--- a/openspec/changes/add-evidence-integrity-governance/specs/audit-investigation/spec.md
+++ /dev/null
@@ -1,43 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Investigators can filter normalized audit fields
-Audit Log User SHALL be able to filter by event type, operation outcome,
-category, target type and identifier, time range, correlation ID, node ID, and
-poller ID in addition to existing filters.
-
-#### Scenario: Multiple filters are applied
-- **WHEN** an Audit Log User selects a time range, failure outcome, and node
-- **THEN** the list and export contain only events satisfying every selected filter
-
-### Requirement: Filter input is bounded and safely queried
-The plugin MUST validate filter values, use prepared parameters, and bound time
-range and result size where applicable.
-
-#### Scenario: Invalid time or identifier filter is submitted
-- **WHEN** a filter fails validation
-- **THEN** the plugin rejects or normalizes it without constructing raw SQL
-
-### Requirement: Low-value self-access events can be coalesced
-The plugin SHALL coalesce only enumerated audit-list and detail-view events
-within a bounded actor, session, event target, and time window.
-
-#### Scenario: Repeated detail views occur in one window
-- **WHEN** the same actor repeatedly views the same audit event within the configured window
-- **THEN** one coalesced event records first time, last time, and occurrence count
-
-#### Scenario: High-value audit action occurs
-- **WHEN** a user exports, purges, changes settings, tests delivery, retries dead letters, or runs verification
-- **THEN** the plugin records the action individually and does not coalesce it
-
-### Requirement: Administrators can verify integrity and delivery completeness
-The plugin SHALL provide a read-only CLI report and Audit Log Admin view that
-verify event hashes, chain continuity, checkpoint coverage, queue state, and
-delivery completeness for a bounded range.
-
-#### Scenario: Verification finds no gaps
-- **WHEN** all events and delivery records in the selected range are consistent
-- **THEN** the report returns success with counts and terminal integrity values
-
-#### Scenario: Verification finds corruption or delivery gaps
-- **WHEN** a hash mismatch, sequence gap, missing checkpoint, or unfinished required delivery exists
-- **THEN** the report returns failure, identifies the affected range, and does not modify evidence
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md
deleted file mode 100644
index d4c0757..0000000
--- a/openspec/changes/add-evidence-integrity-governance/specs/delivery-operations/spec.md
+++ /dev/null
@@ -1,54 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Remote events are queued outside request processing
-The plugin SHALL create a pending delivery row for each finalized event when
-remote Syslog is enabled and SHALL perform network transmission from poller
-processing rather than the originating web or CLI request.
-
-#### Scenario: Web request finalizes while receiver is unavailable
-- **WHEN** an audited web request finishes and the Syslog receiver is unavailable
-- **THEN** the request can complete while a pending delivery remains available for the poller
-
-### Requirement: Transient failures use bounded backoff
-The plugin SHALL schedule transient failures using exponential backoff with
-configurable base delay, maximum delay, maximum attempts, and batch size.
-
-#### Scenario: Connection attempt fails
-- **WHEN** TCP or TLS connection establishment fails transiently
-- **THEN** attempts increment, a bounded error is stored, and `next_attempt` is scheduled in the future
-
-#### Scenario: Socket operation emits an expected warning
-- **WHEN** a Syslog connection or write is refused, reset, or otherwise fails through a PHP stream warning
-- **THEN** the warning is captured as a bounded delivery error and is not forwarded to Cacti's global PHP error handler
-
-#### Scenario: Retry is not yet due
-- **WHEN** the poller selects queued deliveries
-- **THEN** it excludes retry rows whose `next_attempt` is in the future
-
-### Requirement: Permanent and exhausted failures enter dead-letter
-The plugin SHALL move permanent failures and rows that reach maximum attempts to
-dead-letter state.
-
-#### Scenario: Maximum attempts reached
-- **WHEN** a transient failure occurs on the configured final attempt
-- **THEN** the row becomes `dead_letter` and is no longer retried automatically
-
-#### Scenario: Administrator retries dead-letter rows
-- **WHEN** an Audit Log Admin performs a CSRF-valid manual retry
-- **THEN** selected rows return to pending, their retry schedule is reset, and the action is audited
-
-### Requirement: Delivery health is visible
-The plugin SHALL show Audit Log Admin the queue counts by state, oldest pending
-age, last attempt, last successful socket send, and last bounded error.
-
-#### Scenario: Queue exceeds a health threshold
-- **WHEN** pending age or dead-letter count exceeds configured thresholds
-- **THEN** the Audit interface displays a persistent warning and the transition is written to the Cacti log
-
-### Requirement: Retention protects unfinished delivery
-The plugin MUST NOT delete an event that has pending, retry, or dead-letter
-remote delivery state when remote delivery is required by its retention class.
-
-#### Scenario: Retention encounters a failed delivery
-- **WHEN** an event is older than its normal retention age but has dead-letter delivery
-- **THEN** the event and its delivery metadata remain available
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/evidence-integrity/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/evidence-integrity/spec.md
deleted file mode 100644
index a006caa..0000000
--- a/openspec/changes/add-evidence-integrity-governance/specs/evidence-integrity/spec.md
+++ /dev/null
@@ -1,47 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Finalized audit events are authenticated
-The plugin SHALL calculate an HMAC-SHA-256 value over a canonical representation
-of every immutable finalized event field and SHALL record the key identifier
-used without storing the key in the audit row.
-
-#### Scenario: Protected content changes
-- **WHEN** any protected event field is modified after finalization
-- **THEN** integrity verification reports a mismatch
-
-### Requirement: Integrity values form a node-specific chain
-The plugin SHALL assign a monotonically increasing node sequence and SHALL bind
-each finalized event to the preceding chain value for that node.
-
-#### Scenario: Event is removed from the middle of a chain
-- **WHEN** verification processes the remaining sequence
-- **THEN** it reports the missing sequence or previous-value mismatch
-
-#### Scenario: Concurrent events finalize
-- **WHEN** multiple requests finalize concurrently on one node
-- **THEN** sequence allocation produces one deterministic order without duplicate sequence numbers
-
-### Requirement: Integrity checkpoints are delivered remotely
-The plugin SHALL periodically create checkpoint records containing node ID,
-sequence range, terminal chain value, key ID, and checkpoint time and SHALL
-queue them for Syslog delivery.
-
-#### Scenario: Local history is rewritten after checkpoint delivery
-- **WHEN** a verifier compares local state with an independently retained checkpoint
-- **THEN** it reports the inconsistent range
-
-### Requirement: Key rotation preserves verification
-The plugin SHALL support active and historical key identifiers so records remain
-verifiable across key rotation.
-
-#### Scenario: Administrator activates a new key
-- **WHEN** a new integrity key becomes active
-- **THEN** new events use its key ID while historical events retain their original key IDs
-
-### Requirement: Receiver deduplication identity is stable
-The plugin SHALL reuse an event UUID for all transmissions of the same event and
-SHALL document UUID as the receiver deduplication key.
-
-#### Scenario: Receiver receives a duplicate
-- **WHEN** a retry produces another copy of an already observed event UUID
-- **THEN** the receiver can identify it as the same logical event
diff --git a/openspec/changes/add-evidence-integrity-governance/specs/remote-syslog-delivery/spec.md b/openspec/changes/add-evidence-integrity-governance/specs/remote-syslog-delivery/spec.md
deleted file mode 100644
index 72a7cc6..0000000
--- a/openspec/changes/add-evidence-integrity-governance/specs/remote-syslog-delivery/spec.md
+++ /dev/null
@@ -1,74 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Administrators can configure remote Syslog
-The plugin SHALL allow Audit Log Admin users to enable remote Syslog and
-configure receiver hostname or IP, port, transport, payload format, facility,
-application name, node identity, timeout, and message-size limit.
-
-#### Scenario: Save valid Syslog configuration
-- **WHEN** an Audit Log Admin submits a valid Syslog configuration
-- **THEN** the plugin stores the configuration and excludes sensitive values from audit event payloads
-
-#### Scenario: Reject invalid receiver configuration
-- **WHEN** an administrator submits a receiver containing a URI scheme, control characters, embedded credentials, an invalid port, or an excessive timeout
-- **THEN** the plugin rejects the configuration without attempting a network connection
-
-### Requirement: Syslog supports UDP TCP and TLS transports
-The plugin SHALL send Syslog using UDP, TCP, or TLS according to the configured
-transport and SHALL default TLS to port 6514 with peer and hostname verification.
-
-#### Scenario: UDP send succeeds locally
-- **WHEN** the complete datagram is accepted by the local UDP socket
-- **THEN** the delivery state becomes `sent_unconfirmed` and the plugin does not claim receiver acknowledgement
-
-#### Scenario: TCP send succeeds locally
-- **WHEN** a complete RFC 6587 framed record is written to the TCP socket
-- **THEN** the delivery state becomes `sent_unconfirmed`
-
-#### Scenario: TLS verification fails
-- **WHEN** the receiver certificate cannot be verified under the configured TLS policy
-- **THEN** the delivery attempt fails, records a bounded error, and becomes eligible for retry
-
-### Requirement: Syslog records use standardized formats
-The plugin SHALL emit an RFC 5424 header and SHALL support RFC 5424 structured
-data, CEF, or compact JSON as the message payload.
-
-#### Scenario: Format an RFC 5424 record
-- **WHEN** a finalized audit event is formatted for Syslog
-- **THEN** the record contains valid PRI, version, UTC timestamp, node identity, application name, poller identity, message ID, and escaped structured data
-
-#### Scenario: Format a CEF record
-- **WHEN** CEF is selected
-- **THEN** event UUID, actor, source IP, action, outcome, target, severity, node, and correlation ID are mapped to escaped CEF fields
-
-#### Scenario: Format a JSON record
-- **WHEN** JSON is selected
-- **THEN** the RFC 5424 message contains one compact JSON object with bounded normalized audit data
-
-### Requirement: UDP records are bounded without silent truncation
-The plugin MUST reject a UDP record that exceeds the configured maximum datagram
-size and MUST NOT truncate or split an audit event silently.
-
-#### Scenario: UDP record exceeds the maximum
-- **WHEN** a formatted UDP record is larger than the configured maximum
-- **THEN** the delivery enters dead-letter state with an `udp_message_too_large` error
-
-### Requirement: Remote delivery includes stable identity
-Every remote record SHALL include event UUID, correlation ID, stable node ID,
-and poller ID when available.
-
-#### Scenario: Receiver observes a retried event
-- **WHEN** the same event is transmitted more than once after a local failure
-- **THEN** every transmission uses the same event UUID and node identity
-
-### Requirement: Syslog test actions are privileged and audited
-Only Audit Log Admin SHALL be able to test the receiver, and test actions MUST
-use POST and CSRF protection.
-
-#### Scenario: Audit user attempts a delivery test
-- **WHEN** a user without Audit Log Admin invokes the test action
-- **THEN** the plugin returns HTTP 403 and sends no test record
-
-#### Scenario: Administrator sends a test record
-- **WHEN** an Audit Log Admin submits a CSRF-valid test request
-- **THEN** the plugin sends a clearly identified test event and audits the test result without storing secrets
diff --git a/openspec/changes/add-evidence-integrity-governance/tasks.md b/openspec/changes/add-evidence-integrity-governance/tasks.md
deleted file mode 100644
index 6db7ae0..0000000
--- a/openspec/changes/add-evidence-integrity-governance/tasks.md
+++ /dev/null
@@ -1,72 +0,0 @@
-## 1. Syslog Schema and Configuration
-
-- [x] 1.1 Add an idempotent Syslog delivery-queue schema with event UUID, destination fingerprint, state, attempt schedule, bounded error, and timestamps
-- [x] 1.2 Add a separate Remote Syslog settings group without changing the existing local-file settings
-- [x] 1.3 Add validated settings for receiver, port, UDP/TCP/TLS transport, RFC 5424/CEF/JSON payload, facility, application name, node identity, timeout, UDP size limit, retry limits, and batch size
-- [x] 1.4 Add TLS peer and hostname verification settings with optional CA and client-certificate paths, secure defaults, and secret-safe display and auditing
-- [x] 1.5 Restrict Syslog configuration and test controls to Audit Log Admin and enforce POST plus CSRF validation for state-changing actions
-
-## 2. Syslog Record Formatting
-
-- [x] 2.1 Implement RFC 5424 PRI, UTC timestamp, hostname, application, process, message ID, structured-data escaping, and normalized field mapping
-- [x] 2.2 Implement compact bounded JSON payload formatting with event UUID, correlation ID, actor, source, action, outcome, target, node, and poller identity
-- [x] 2.3 Implement CEF payload mapping and escaping for normalized audit fields
-- [x] 2.4 Implement RFC 6587 octet-count framing for TCP and TLS while keeping one complete record per UDP datagram
-- [x] 2.5 Reject oversized UDP records without truncation or splitting and classify them as permanent delivery errors
-- [x] 2.6 Add unit tests and fixtures for RFC 5424, JSON, CEF, escaping, framing, size limits, and stable event identity
-
-## 3. Syslog Queue and Transports
-
-- [x] 3.1 Enqueue one pending Syslog delivery row when an audit event is finalized and Syslog is enabled, without opening a network connection in the request
-- [x] 3.2 Implement bounded UDP writes and record successful local socket acceptance as sent_unconfirmed
-- [x] 3.3 Implement bounded TCP connection and complete framed writes with reusable connections within a poller batch
-- [x] 3.4 Implement TLS connection and framed writes with peer and hostname verification enabled by default
-- [x] 3.5 Process due queue rows from the Cacti poller in configurable bounded batches
-- [x] 3.6 Implement exponential retry scheduling for transient failures and dead-letter transitions for permanent or exhausted failures
-- [x] 3.7 Preserve event UUID and node identity across retries and prevent duplicate active queue rows for the same event and destination
-- [x] 3.8 Add transport tests using local UDP, TCP, and TLS receivers, including timeouts, partial writes, certificate failure, receiver outage, and duplicate processing
-
-## 4. Syslog Administration and Operations
-
-- [x] 4.1 Add an audited Audit Log Admin test action that emits a clearly identified Syslog test record without exposing secrets
-- [x] 4.2 Add queue-health reporting for state counts, oldest pending age, last attempt, last successful socket send, and last bounded error
-- [x] 4.3 Add configurable health thresholds, persistent Audit UI warnings, and Cacti log transitions for unhealthy and recovered states
-- [x] 4.4 Add an audited Audit Log Admin action to retry selected dead-letter rows
-- [x] 4.5 Ensure retention and purge do not remove events with unfinished required Syslog delivery
-- [x] 4.6 Document receiver setup, facility and format choices, UDP limitations, TCP/TLS recommendations, SIEM ingestion, and the sent_unconfirmed delivery semantics
-- [x] 4.7 Run PHP compatibility, security, migration, and regression tests and verify existing local-file delivery remains unchanged
-
-## 5. Evidence Integrity
-
-- [ ] 5.1 Add idempotent schema for node sequence, previous-chain value, event HMAC, key identifier, and integrity checkpoints
-- [ ] 5.2 Define and test a versioned canonical representation of immutable finalized audit fields
-- [ ] 5.3 Implement serialized node-specific sequence allocation and HMAC-SHA-256 chain calculation during finalization
-- [ ] 5.4 Add active and historical key identifiers with secure key-loading and rotation behavior that never stores keys in audit rows
-- [ ] 5.5 Create periodic count- and time-based checkpoint records and enqueue them through the Syslog queue
-- [ ] 5.6 Add integrity tests for mutation, deletion, sequence gaps, concurrent finalization, key rotation, and checkpoint comparison
-
-## 6. Governance and Archival
-
-- [ ] 6.1 Add retention-class schema and administration for age, required Syslog delivery, archive requirement, and purge eligibility
-- [ ] 6.2 Assign a retention class to each event with a safe default and migration behavior for existing events
-- [ ] 6.3 Add audited legal-hold placement and release with reason, actor, time, scope, and optional case identifier
-- [ ] 6.4 Add archival generation with event ranges, counts, node ranges, terminal integrity values, policy identity, and authenticated manifests
-- [ ] 6.5 Update scheduled retention and manual purge to enforce legal hold, delivery, archive, and checkpoint prerequisites and report protected counts
-- [ ] 6.6 Add governance tests for held ranges, mixed protected selections, archive verification, retention classes, and uninstall cleanup
-
-## 7. Investigation and Verification
-
-- [ ] 7.1 Add indexed normalized fields needed for event type, outcome, category, target, correlation ID, node ID, and poller ID filters
-- [ ] 7.2 Add bounded validated Audit Log User filters using safe parameterized queries for list and export
-- [ ] 7.3 Add configurable coalescing for enumerated low-risk audit list and detail reads, preserving first time, last time, actor, target, session, and occurrence count
-- [ ] 7.4 Exclude exports, purge, settings, delivery tests, dead-letter retries, verification, and all failures from coalescing
-- [ ] 7.5 Add a read-only CLI verification report for hashes, chain continuity, checkpoints, queue state, and delivery completeness over a bounded range
-- [ ] 7.6 Add an Audit Log Admin verification view with the same bounded checks and no evidence mutation
-- [ ] 7.7 Add investigation and verification tests for combined filters, invalid input, coalescing boundaries, corruption, chain gaps, and incomplete delivery
-
-## 8. Release Readiness
-
-- [ ] 8.1 Review all new tables and settings for install, upgrade, replication, and complete uninstall behavior
-- [ ] 8.2 Review permissions so Audit Log User remains read-only and all configuration, retry, legal-hold, purge, archive, and verification administration requires Audit Log Admin
-- [ ] 8.3 Update plugin version metadata, changelog, administrator documentation, and upgrade notes
-- [ ] 8.4 Run the full available test suite and perform a final security review of network destinations, TLS, error handling, secrets, CSRF, SQL, output encoding, and denial-of-service bounds

From bc08d10db72ff78da631d9eb5b75f31e017bff15 Mon Sep 17 00:00:00 2001
From: Sean Mancini 
Date: Sat, 25 Jul 2026 10:31:33 -0400
Subject: [PATCH 21/36] Include audit evidence in CEF records

---
 CHANGELOG.md                    |  1 +
 README.md                       |  5 +++++
 audit_syslog.php                | 34 ++++++++++++++++++++++++++++++++-
 tests/syslog_functions_test.php | 10 ++++++++++
 4 files changed, 49 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95088e0..d56164f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
 * security: Restrict Syslog settings, tests, and dead-letter retries to Audit Log Admin with validated destinations and CSRF-protected POST actions
 * security: Preserve unfinished Syslog evidence during scheduled retention and manual purge
 * issue: Capture expected Syslog connection and write warnings without leaking PHP notices into the Cacti poller log
+* feature: Include redacted submitted, object, and detail data in CEF Syslog records
 * feature: Verify user realm permission saves against the resulting database state
 * security: Group Audit Log User and Audit Log Admin permissions under Audit Plugin
 * feature: Add normalized compliance event identifiers, categories, actors, targets, outcomes, timing, and integrity metadata
diff --git a/README.md b/README.md
index 3b6990c..cbffe64 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,11 @@ All records use an RFC 5424 header. TCP and TLS records use RFC 6587
 octet-count framing. TLS always verifies the receiver certificate and hostname;
 there is no setting to disable verification.
 
+CEF records include the same redacted submitted request data, captured object
+data, and event details available to JSON consumers in the `cs4`, `cs5`, and
+`cs6` custom string fields. The corresponding labels are `Submitted Data`,
+`Object Data`, and `Details`.
+
 UDP sends one complete event per datagram. If a formatted event is larger than
 the configured UDP maximum, it is moved to dead-letter instead of being
 truncated or split. TCP or TLS is recommended when events can be large or when
diff --git a/audit_syslog.php b/audit_syslog.php
index d20bf4f..f73f406 100644
--- a/audit_syslog.php
+++ b/audit_syslog.php
@@ -307,6 +307,32 @@ function audit_syslog_cef_severity($severity) {
 	return isset($map[$severity]) ? $map[$severity] : 3;
 }
 
+function audit_syslog_cef_event_field($value) {
+	if (is_string($value) && $value !== '') {
+		$decoded = audit_json_decode($value, $error);
+		if ($error === null) {
+			$value = $decoded;
+		}
+	}
+
+	if (is_array($value)) {
+		return audit_json_encode(
+			audit_redact_sensitive_data($value),
+			JSON_UNESCAPED_SLASHES
+		);
+	}
+
+	if ($value === null) {
+		return '';
+	}
+
+	if (is_bool($value)) {
+		return $value ? 'true' : 'false';
+	}
+
+	return audit_redact_sensitive_value((string) $value);
+}
+
 function audit_syslog_cef_payload($event, $config) {
 	$severity = audit_syslog_cef_severity($event['severity'] ?? 'info');
 	$header = array(
@@ -331,7 +357,13 @@ function audit_syslog_cef_payload($event, $config) {
 		'cs3Label' => 'Node ID',
 		'cs3' => $config['node_id'],
 		'cn1Label' => 'Poller ID',
-		'cn1' => $config['poller_id']
+		'cn1' => $config['poller_id'],
+		'cs4Label' => 'Submitted Data',
+		'cs4' => audit_syslog_cef_event_field($event['post'] ?? ''),
+		'cs5Label' => 'Object Data',
+		'cs5' => audit_syslog_cef_event_field($event['object_data'] ?? ''),
+		'cs6Label' => 'Details',
+		'cs6' => audit_syslog_cef_event_field($event['details'] ?? '')
 	);
 	$encoded_header = array_map('audit_syslog_cef_escape_header', $header);
 	$encoded_extension = array();
diff --git a/tests/syslog_functions_test.php b/tests/syslog_functions_test.php
index f23330f..0adf595 100644
--- a/tests/syslog_functions_test.php
+++ b/tests/syslog_functions_test.php
@@ -56,6 +56,8 @@ function audit_syslog_test_event() {
 		'target_id' => '4',
 		'ip_address' => '192.0.2.10',
 		'event_time' => '2026-07-24 15:16:17.123456',
+		'post' => '{"id":4,"description":"new value","password":"must-not-leak"}',
+		'object_data' => '[{"id":4,"description":"old value"}]',
 		'details' => '{"test":true}',
 		'integrity_hash' => str_repeat('a', 64)
 	);
@@ -226,6 +228,14 @@ function audit_syslog_test_remove_tls_material($material) {
 	'CEF payloads must contain normalized vendor, product, event, action, and severity fields.');
 audit_syslog_test_assert(strpos($cef['record'], 'externalId=32e0a97d-d9e8-4abc-8f41-2bbbc50793ca') !== false,
 	'CEF payloads must expose the stable event UUID for deduplication.');
+audit_syslog_test_assert(strpos($cef['record'], 'cs4Label=Submitted Data cs4={"id":4,"description":"new value","password":"[REDACTED]"}') !== false,
+	'CEF payloads must expose redacted submitted data for investigation.');
+audit_syslog_test_assert(strpos($cef['record'], 'cs5Label=Object Data cs5=[{"id":4,"description":"old value"}]') !== false,
+	'CEF payloads must expose the stored object data available to JSON consumers.');
+audit_syslog_test_assert(strpos($cef['record'], 'cs6Label=Details cs6={"test":true}') !== false,
+	'CEF payloads must expose normalized event details.');
+audit_syslog_test_assert(strpos($cef['record'], 'must-not-leak') === false,
+	'CEF payloads must defensively redact sensitive submitted fields.');
 
 $rfc_config = audit_syslog_test_config(array('format' => 'rfc5424'));
 $rfc = audit_syslog_record($event, $rfc_config);

From 42ab811a948ba44141ba1a064cd81cfbaf1eeabf Mon Sep 17 00:00:00 2001
From: Sean Mancini 
Date: Sat, 25 Jul 2026 16:00:29 -0400
Subject: [PATCH 22/36] Add PHP code quality tooling (PSR-12, PHPStan,
 PHP-CS-Fixer)

- composer.json with php-cs-fixer ^3.86, phpstan ^2.2, pest ^2
- .php-cs-fixer.php copied verbatim from Cacti develop (tabs, custom ruleset)
- .phpstan.neon matching Cacti shape, paths adapted, level 8
- phpstan/stubs/cacti.stubs.php typed Cacti function signatures
- .editorconfig (tabs for PHP/JS/SH, binary for po/pot/mo)
- .gitignore updated for vendor/, caches, baseline

Phase 1 of code quality sweep. No functionality changes.
---
 .editorconfig                 |   21 +
 .gitignore                    |   13 +
 .php-cs-fixer.php             |  227 ++
 .phpstan.neon                 |   41 +
 composer.json                 |   38 +
 composer.lock                 | 5556 +++++++++++++++++++++++++++++++++
 phpstan-baseline.neon         |    2 +
 phpstan/stubs/cacti.stubs.php |  381 +++
 8 files changed, 6279 insertions(+)
 create mode 100644 .editorconfig
 create mode 100644 .php-cs-fixer.php
 create mode 100644 .phpstan.neon
 create mode 100644 composer.json
 create mode 100644 composer.lock
 create mode 100644 phpstan-baseline.neon
 create mode 100644 phpstan/stubs/cacti.stubs.php

diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..d4407a6
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,21 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.php]
+indent_style = tab
+indent_size = 4
+
+[*.{js,sh}]
+indent_style = tab
+indent_size = 4
+
+[*.{po,pot,mo}]
+binary = true
+
+[*.md]
+trim_trailing_whitespace = false
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 3dd84d9..17eb625 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,16 @@
 
 
 locales/po/*.mo
+
+# Composer
+vendor/
+
+# PHPStan
+phpstan.cache/
+.phpstan-baseline.neon.local
+
+# PHP-CS-Fixer
+.php-cs-fixer.cache
+
+# Pest / PHPUnit
+.phpunit.result.cache
diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
new file mode 100644
index 0000000..b1cccc6
--- /dev/null
+++ b/.php-cs-fixer.php
@@ -0,0 +1,227 @@
+exclude('locales')
+	->exclude('js')
+	->exclude('.github')
+	->exclude('openspec')
+	->exclude('vendor')
+	->in(__DIR__);
+
+$config = new PhpCsFixer\Config();
+$config
+	->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
+	->setRiskyAllowed(true)
+	->setIndent("\t")
+	->setLineEnding("\n")
+	->setRules(array(
+		'header_comment'                                 => false,
+		'comment_to_phpdoc'                              => true,
+		'phpdoc_align'                                   => true,
+		'list_syntax'                                    => ['syntax' => 'short'],
+		'array_syntax'                                   => ['syntax' => 'short'],
+		'trim_array_spaces'                              => false,
+		'no_whitespace_before_comma_in_array'             => true,
+		'whitespace_after_comma_in_array'                 => true,
+		'no_multiline_whitespace_around_double_arrow'     => true,
+		'no_whitespace_in_blank_line'                    => true,
+		'no_trailing_whitespace'                         => true,
+		'normalize_index_brace'                          => true,
+		'no_mixed_echo_print'                            => ['use' => 'print'],
+		'no_spaces_after_function_name'                  => true,
+		'braces'                                         => [
+			'position_after_functions_and_oop_constructs' => 'same',
+			'position_after_control_structures'           => 'same',
+			'allow_single_line_closure'                   => true
+		],
+		'braces_position'                                => [
+			'anonymous_classes_opening_brace'   => 'same_line',
+			'anonymous_functions_opening_brace' => 'same_line',
+			'classes_opening_brace'             => 'same_line',
+			'functions_opening_brace'           => 'same_line'
+		],
+		'single_blank_line_at_eof'                       => true,
+		'method_chaining_indentation'                    => true,
+		'indentation_type'                               => true,
+		'constant_case'                                  => true,
+		'lowercase_keywords'                             => true,
+		'line_ending'                                    => true,
+		'magic_constant_casing'                          => true,
+		'native_function_casing'                         => true,
+		'elseif'                                         => true,
+		'include'                                        => false,
+		'no_alternative_syntax'                          => true,
+		'no_superfluous_elseif'                          => true,
+		'no_trailing_comma_in_singleline'                => true,
+		'no_unneeded_braces'                             => true,
+		'no_useless_else'                                => false,
+		'yoda_style'                                     => [
+			'equal'             => false,
+			'identical'         => false,
+			'less_and_greater'  => null,
+			'always_move_variable' => false
+		],
+		'declare_equal_normalize'                        => ['space' => 'single'],
+		'dir_constant'                                   => true,
+		'single_space_around_construct'                  => [
+			'constructs_followed_by_a_single_space' => [
+				'abstract',
+				'as',
+				'attribute',
+				'break',
+				'case',
+				'catch',
+				'class',
+				'clone',
+				'const',
+				'const_import',
+				'continue',
+				'do',
+				'echo',
+				'else',
+				'elseif',
+				'extends',
+				'final',
+				'finally',
+				'for',
+				'foreach',
+				'function',
+				'function_import',
+				'global',
+				'goto',
+				'if',
+				'implements',
+				'instanceof',
+				'insteadof',
+				'interface',
+				'match',
+				'named_argument',
+				'new',
+				'open_tag_with_echo',
+				'php_open',
+				'print',
+				'private',
+				'protected',
+				'public',
+				'return',
+				'static',
+				'throw',
+				'trait',
+				'try',
+				'use',
+				'use_lambda',
+				'use_trait',
+				'var',
+				'while',
+				'yield',
+				'yield_from'
+			]
+		],
+		'concat_space'                                   => ['spacing' => 'one'],
+		'switch_case_semicolon_to_colon'                 => true,
+		'switch_case_space'                              => true,
+		'switch_continue_to_break'                       => true,
+		'logical_operators'                              => true,
+		'function_declaration'                           => ['closure_function_spacing' => 'one'],
+		'spaces_inside_parentheses'                       => true,
+		'binary_operator_spaces'                         => [
+			'operators' => [
+				'+='  => 'align_single_space',
+				'===' => 'align_single_space_minimal',
+				'='   => 'align_single_space',
+				'|'   => 'single_space',
+				'=>'  => 'align',
+				'!='  => 'align'
+			]
+		],
+		'not_operator_with_space'                        => false,
+		'no_spaces_around_offset'                        => ['positions' => ['outside', 'inside']],
+		'standardize_not_equals'                         => true,
+		'ternary_operator_spaces'                        => true,
+		'full_opening_tag'                               => false,
+		'linebreak_after_opening_tag'                    => false,
+		'phpdoc_add_missing_param_annotation'            => true,
+		'no_extra_blank_lines'                           => [
+			'tokens' => [
+				'break',
+				'case',
+				'continue',
+				'curly_brace_block',
+				'default',
+				'extra',
+				'parenthesis_brace_block',
+				'return',
+				'square_brace_block',
+				'switch',
+				'throw',
+				'use'
+			]
+		],
+		'no_empty_statement'                             => true,
+		'multiline_whitespace_before_semicolons'         => true,
+		'no_singleline_whitespace_before_semicolons'     => true,
+		'semicolon_after_instruction'                    => false,
+		'space_after_semicolon'                          => ['remove_in_empty_for_expressions' => true],
+		'blank_line_before_statement'                     => [
+			'statements' => [
+				'continue',
+				'break',
+				'declare',
+				'do',
+				'for',
+				'foreach',
+				'goto',
+				'if',
+				'return',
+				'switch',
+				'throw',
+				'try',
+				'while',
+				'yield',
+				'yield_from'
+			]
+		],
+		'explicit_string_variable'                       => false,
+		'single_quote'                                   => true,
+		'string_line_ending'                             => true,
+		'strict_param'                                   => true,
+		'align_multiline_comment'                         => ['comment_type' => 'phpdocs_like'],
+		'single_line_comment_spacing'                     => true,
+		'single_line_comment_style'                       => true,
+		'multiline_comment_opening_closing'              => true
+	))
+	->setFinder($finder);
+
+return $config;
\ No newline at end of file
diff --git a/.phpstan.neon b/.phpstan.neon
new file mode 100644
index 0000000..58d5867
--- /dev/null
+++ b/.phpstan.neon
@@ -0,0 +1,41 @@
+includes:
+    - phpstan-baseline.neon
+
+parameters:
+    parallel:
+        maximumNumberOfProcesses: 8
+    paths:
+        - setup.php
+        - audit.php
+        - audit_functions.php
+        - audit_syslog.php
+        - index.php
+        - locales/index.php
+        - locales/LC_MESSAGES/index.php
+        - tests/
+    excludePaths:
+        analyse:
+            - locales/*
+            - js/*
+            - vendor/*
+            - '*.mo'
+            - '*.po'
+            - '*.pot'
+    scanFiles:
+        - phpstan/stubs/cacti.stubs.php
+    level: 8
+    reportUnmatchedIgnoredErrors: false
+    ignoreErrors:
+        - identifier: missingType.iterableValue
+        - identifier: if.alwaysFalse
+        - identifier: notEqual.alwaysFalse
+        - identifier: notEqual.alwaysTrue
+        - identifier: equal.alwaysTrue
+        - identifier: if.alwaysTrue
+        - identifier: while.alwaysTrue
+        - identifier: greater.alwaysFalse
+        - identifier: identical.alwaysTrue
+        - identifier: identical.alwaysFalse
+        - identifier: booleanAnd.leftAlwaysTrue
+        - identifier: booleanAnd.leftAlwaysFalse
+        - identifier: function.alreadyNarrowedType
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..cea3159
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,38 @@
+{
+    "name": "cacti/plugin-audit",
+    "description": "Cacti Audit Plugin - logs GUI and CLI activities to an audit trail.",
+    "type": "cacti-plugin",
+    "license": "GPL-2.0-only",
+    "require": {
+        "php": "^8.1",
+        "ext-json": "*",
+        "ext-mbstring": "*",
+        "ext-sockets": "*"
+    },
+    "require-dev": {
+        "friendsofphp/php-cs-fixer": "^3.86",
+        "pestphp/pest": "^2.0",
+        "pestphp/pest-plugin-drift": "^2.0",
+        "phpstan/phpstan": "^2.2"
+    },
+    "scripts": {
+        "php-cs-fixer": "php-cs-fixer fix --dry-run --diff --config=./.php-cs-fixer.php",
+        "php-cs-fixit": "php-cs-fixer fix --config=./.php-cs-fixer.php",
+        "phpstan": "phpstan analyse --memory-limit=512M",
+        "test": "pest --display-warnings"
+    },
+    "authors": [
+        {
+            "name": "The Cacti Group",
+            "email": "developers@cacti.net"
+        }
+    ],
+    "config": {
+        "sort-packages": true,
+        "allow-plugins": {
+            "pestphp/pest-plugin": true
+        }
+    },
+    "minimum-stability": "stable",
+    "prefer-stable": true
+}
\ No newline at end of file
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..1d3f33a
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,5556 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "9747cc3f788049ed236529a77db2b739",
+    "packages": [],
+    "packages-dev": [
+        {
+            "name": "brianium/paratest",
+            "version": "v7.4.9",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/paratestphp/paratest.git",
+                "reference": "633c0987ecf6d9b057431225da37b088aa9274a5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/paratestphp/paratest/zipball/633c0987ecf6d9b057431225da37b088aa9274a5",
+                "reference": "633c0987ecf6d9b057431225da37b088aa9274a5",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-pcre": "*",
+                "ext-reflection": "*",
+                "ext-simplexml": "*",
+                "fidry/cpu-core-counter": "^1.2.0",
+                "jean85/pretty-package-versions": "^2.0.6",
+                "php": "~8.2.0 || ~8.3.0 || ~8.4.0",
+                "phpunit/php-code-coverage": "^10.1.16",
+                "phpunit/php-file-iterator": "^4.1.0",
+                "phpunit/php-timer": "^6.0.0",
+                "phpunit/phpunit": "^10.5.47",
+                "sebastian/environment": "^6.1.0",
+                "symfony/console": "^6.4.7 || ^7.1.5",
+                "symfony/process": "^6.4.7 || ^7.1.5"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^12.0.0",
+                "ext-pcov": "*",
+                "ext-posix": "*",
+                "phpstan/phpstan": "^1.12.6",
+                "phpstan/phpstan-deprecation-rules": "^1.2.1",
+                "phpstan/phpstan-phpunit": "^1.4.0",
+                "phpstan/phpstan-strict-rules": "^1.6.1",
+                "squizlabs/php_codesniffer": "^3.10.3",
+                "symfony/filesystem": "^6.4.3 || ^7.1.5"
+            },
+            "bin": [
+                "bin/paratest",
+                "bin/paratest_for_phpstorm"
+            ],
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "ParaTest\\": [
+                        "src/"
+                    ]
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Brian Scaturro",
+                    "email": "scaturrob@gmail.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Filippo Tessarotto",
+                    "email": "zoeslam@gmail.com",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Parallel testing for PHP",
+            "homepage": "https://github.com/paratestphp/paratest",
+            "keywords": [
+                "concurrent",
+                "parallel",
+                "phpunit",
+                "testing"
+            ],
+            "support": {
+                "issues": "https://github.com/paratestphp/paratest/issues",
+                "source": "https://github.com/paratestphp/paratest/tree/v7.4.9"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sponsors/Slamdunk",
+                    "type": "github"
+                },
+                {
+                    "url": "https://paypal.me/filippotessarotto",
+                    "type": "paypal"
+                }
+            ],
+            "time": "2025-06-25T06:09:59+00:00"
+        },
+        {
+            "name": "clue/ndjson-react",
+            "version": "v1.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/clue/reactphp-ndjson.git",
+                "reference": "392dc165fce93b5bb5c637b67e59619223c931b0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0",
+                "reference": "392dc165fce93b5bb5c637b67e59619223c931b0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3",
+                "react/stream": "^1.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35",
+                "react/event-loop": "^1.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Clue\\React\\NDJson\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering"
+                }
+            ],
+            "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.",
+            "homepage": "https://github.com/clue/reactphp-ndjson",
+            "keywords": [
+                "NDJSON",
+                "json",
+                "jsonlines",
+                "newline",
+                "reactphp",
+                "streaming"
+            ],
+            "support": {
+                "issues": "https://github.com/clue/reactphp-ndjson/issues",
+                "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://clue.engineering/support",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/clue",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-12-23T10:58:28+00:00"
+        },
+        {
+            "name": "composer/pcre",
+            "version": "3.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/pcre.git",
+                "reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
+                "reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.4 || ^8.0"
+            },
+            "conflict": {
+                "phpstan/phpstan": "<2.2.2"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^2",
+                "phpstan/phpstan-deprecation-rules": "^2",
+                "phpstan/phpstan-strict-rules": "^2",
+                "phpunit/phpunit": "^9"
+            },
+            "type": "library",
+            "extra": {
+                "phpstan": {
+                    "includes": [
+                        "extension.neon"
+                    ]
+                },
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\Pcre\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "http://seld.be"
+                }
+            ],
+            "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+            "keywords": [
+                "PCRE",
+                "preg",
+                "regex",
+                "regular expression"
+            ],
+            "support": {
+                "issues": "https://github.com/composer/pcre/issues",
+                "source": "https://github.com/composer/pcre/tree/3.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-06-07T11:47:49+00:00"
+        },
+        {
+            "name": "composer/semver",
+            "version": "3.4.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/semver.git",
+                "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+                "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.3.2 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.11",
+                "symfony/phpunit-bridge": "^3 || ^7"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\Semver\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nils Adermann",
+                    "email": "naderman@naderman.de",
+                    "homepage": "http://www.naderman.de"
+                },
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "http://seld.be"
+                },
+                {
+                    "name": "Rob Bast",
+                    "email": "rob.bast@gmail.com",
+                    "homepage": "http://robbast.nl"
+                }
+            ],
+            "description": "Semver library that offers utilities, version constraint parsing and validation.",
+            "keywords": [
+                "semantic",
+                "semver",
+                "validation",
+                "versioning"
+            ],
+            "support": {
+                "irc": "ircs://irc.libera.chat:6697/composer",
+                "issues": "https://github.com/composer/semver/issues",
+                "source": "https://github.com/composer/semver/tree/3.4.4"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-20T19:15:30+00:00"
+        },
+        {
+            "name": "composer/xdebug-handler",
+            "version": "3.0.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/xdebug-handler.git",
+                "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef",
+                "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef",
+                "shasum": ""
+            },
+            "require": {
+                "composer/pcre": "^1 || ^2 || ^3",
+                "php": "^7.2.5 || ^8.0",
+                "psr/log": "^1 || ^2 || ^3"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.0",
+                "phpstan/phpstan-strict-rules": "^1.1",
+                "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Composer\\XdebugHandler\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "John Stevenson",
+                    "email": "john-stevenson@blueyonder.co.uk"
+                }
+            ],
+            "description": "Restarts a process without Xdebug.",
+            "keywords": [
+                "Xdebug",
+                "performance"
+            ],
+            "support": {
+                "irc": "ircs://irc.libera.chat:6697/composer",
+                "issues": "https://github.com/composer/xdebug-handler/issues",
+                "source": "https://github.com/composer/xdebug-handler/tree/3.0.5"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-05-06T16:37:16+00:00"
+        },
+        {
+            "name": "doctrine/deprecations",
+            "version": "1.1.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/deprecations.git",
+                "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+                "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "phpunit/phpunit": "<=7.5 || >=14"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^9 || ^12 || ^14",
+                "phpstan/phpstan": "1.4.10 || 2.1.30",
+                "phpstan/phpstan-phpunit": "^1.0 || ^2",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
+                "psr/log": "^1 || ^2 || ^3"
+            },
+            "suggest": {
+                "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Deprecations\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+            "homepage": "https://www.doctrine-project.org/",
+            "support": {
+                "issues": "https://github.com/doctrine/deprecations/issues",
+                "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
+            },
+            "time": "2026-02-07T07:09:04+00:00"
+        },
+        {
+            "name": "ergebnis/agent-detector",
+            "version": "1.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ergebnis/agent-detector.git",
+                "reference": "e211f17928c8b95a51e06040792d57f5462fb271"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271",
+                "reference": "e211f17928c8b95a51e06040792d57f5462fb271",
+                "shasum": ""
+            },
+            "require": {
+                "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0"
+            },
+            "require-dev": {
+                "ergebnis/composer-normalize": "^2.51.0",
+                "ergebnis/license": "^2.7.0",
+                "ergebnis/php-cs-fixer-config": "^6.60.2",
+                "ergebnis/phpstan-rules": "^2.13.1",
+                "ergebnis/phpunit-slow-test-detector": "^2.24.0",
+                "ergebnis/rector-rules": "^1.18.1",
+                "fakerphp/faker": "^1.24.1",
+                "infection/infection": "^0.26.6",
+                "phpstan/extension-installer": "^1.4.3",
+                "phpstan/phpstan": "^2.1.54",
+                "phpstan/phpstan-deprecation-rules": "^2.0.4",
+                "phpstan/phpstan-phpunit": "^2.0.16",
+                "phpstan/phpstan-strict-rules": "^2.0.10",
+                "phpunit/phpunit": "^9.6.34",
+                "rector/rector": "^2.4.2"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.2-dev"
+                },
+                "composer-normalize": {
+                    "indent-size": 2,
+                    "indent-style": "space"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Ergebnis\\AgentDetector\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Andreas Möller",
+                    "email": "am@localheinz.com",
+                    "homepage": "https://localheinz.com"
+                }
+            ],
+            "description": "Provides a detector for detecting the presence of an agent.",
+            "homepage": "https://github.com/ergebnis/agent-detector",
+            "support": {
+                "issues": "https://github.com/ergebnis/agent-detector/issues",
+                "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md",
+                "source": "https://github.com/ergebnis/agent-detector"
+            },
+            "time": "2026-05-07T08:19:07+00:00"
+        },
+        {
+            "name": "evenement/evenement",
+            "version": "v3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/igorw/evenement.git",
+                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
+                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9 || ^6"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Evenement\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Igor Wiedler",
+                    "email": "igor@wiedler.ch"
+                }
+            ],
+            "description": "Événement is a very simple event dispatching library for PHP",
+            "keywords": [
+                "event-dispatcher",
+                "event-emitter"
+            ],
+            "support": {
+                "issues": "https://github.com/igorw/evenement/issues",
+                "source": "https://github.com/igorw/evenement/tree/v3.0.2"
+            },
+            "time": "2023-08-08T05:53:35+00:00"
+        },
+        {
+            "name": "fidry/cpu-core-counter",
+            "version": "1.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theofidry/cpu-core-counter.git",
+                "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
+                "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "require-dev": {
+                "fidry/makefile": "^0.2.0",
+                "fidry/php-cs-fixer-config": "^1.1.2",
+                "phpstan/extension-installer": "^1.2.0",
+                "phpstan/phpstan": "^2.0",
+                "phpstan/phpstan-deprecation-rules": "^2.0.0",
+                "phpstan/phpstan-phpunit": "^2.0",
+                "phpstan/phpstan-strict-rules": "^2.0",
+                "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+                "webmozarts/strict-phpunit": "^7.5"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Fidry\\CpuCoreCounter\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Théo FIDRY",
+                    "email": "theo.fidry@gmail.com"
+                }
+            ],
+            "description": "Tiny utility to get the number of CPU cores.",
+            "keywords": [
+                "CPU",
+                "core"
+            ],
+            "support": {
+                "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+                "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theofidry",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-14T07:29:31+00:00"
+        },
+        {
+            "name": "filp/whoops",
+            "version": "2.18.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/filp/whoops.git",
+                "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+                "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0",
+                "psr/log": "^1.0.1 || ^2.0 || ^3.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "^1.0",
+                "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3",
+                "symfony/var-dumper": "^4.0 || ^5.0"
+            },
+            "suggest": {
+                "symfony/var-dumper": "Pretty print complex values better with var-dumper available",
+                "whoops/soap": "Formats errors as SOAP responses"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.7-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Whoops\\": "src/Whoops/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Filipe Dobreira",
+                    "homepage": "https://github.com/filp",
+                    "role": "Developer"
+                }
+            ],
+            "description": "php error handling for cool kids",
+            "homepage": "https://filp.github.io/whoops/",
+            "keywords": [
+                "error",
+                "exception",
+                "handling",
+                "library",
+                "throwable",
+                "whoops"
+            ],
+            "support": {
+                "issues": "https://github.com/filp/whoops/issues",
+                "source": "https://github.com/filp/whoops/tree/2.18.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/denis-sokolov",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-08T12:00:00+00:00"
+        },
+        {
+            "name": "friendsofphp/php-cs-fixer",
+            "version": "v3.95.17",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
+                "reference": "0ee88422118f3cc59c8c3def222ba7f1493b6d5b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/0ee88422118f3cc59c8c3def222ba7f1493b6d5b",
+                "reference": "0ee88422118f3cc59c8c3def222ba7f1493b6d5b",
+                "shasum": ""
+            },
+            "require": {
+                "clue/ndjson-react": "^1.3",
+                "composer/semver": "^3.4",
+                "composer/xdebug-handler": "^3.0.5",
+                "ergebnis/agent-detector": "^1.2",
+                "ext-filter": "*",
+                "ext-hash": "*",
+                "ext-json": "*",
+                "ext-tokenizer": "*",
+                "fidry/cpu-core-counter": "^1.3",
+                "php": "^7.4 || ^8.0",
+                "react/child-process": "^0.6.6",
+                "react/event-loop": "^1.5",
+                "react/socket": "^1.16",
+                "react/stream": "^1.4",
+                "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0",
+                "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/polyfill-mbstring": "^1.37",
+                "symfony/polyfill-php80": "^1.37",
+                "symfony/polyfill-php81": "^1.37",
+                "symfony/polyfill-php84": "^1.37",
+                "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0",
+                "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "facile-it/paraunit": "^1.3.1 || ^2.11.0",
+                "infection/infection": "^0.32.7",
+                "justinrainbow/json-schema": "^6.10.0",
+                "keradus/cli-executor": "^2.3",
+                "mikey179/vfsstream": "^1.6.12",
+                "php-coveralls/php-coveralls": "^2.9.1",
+                "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
+                "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
+                "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6",
+                "symfony/polyfill-php85": "^1.38",
+                "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1",
+                "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1"
+            },
+            "suggest": {
+                "ext-dom": "For handling output formats in XML",
+                "ext-mbstring": "For handling non-UTF8 characters."
+            },
+            "bin": [
+                "php-cs-fixer"
+            ],
+            "type": "application",
+            "autoload": {
+                "psr-4": {
+                    "PhpCsFixer\\": "src/"
+                },
+                "exclude-from-classmap": [
+                    "src/**/Internal/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Dariusz Rumiński",
+                    "email": "dariusz.ruminski@gmail.com"
+                }
+            ],
+            "description": "A tool to automatically fix PHP code style",
+            "keywords": [
+                "Static code analysis",
+                "fixer",
+                "standards",
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
+                "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.17"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/keradus",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-07-24T13:54:39+00:00"
+        },
+        {
+            "name": "jean85/pretty-package-versions",
+            "version": "2.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Jean85/pretty-package-versions.git",
+                "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
+                "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
+                "shasum": ""
+            },
+            "require": {
+                "composer-runtime-api": "^2.1.0",
+                "php": "^7.4|^8.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "^3.2",
+                "jean85/composer-provided-replaced-stub-package": "^1.0",
+                "phpstan/phpstan": "^2.0",
+                "phpunit/phpunit": "^7.5|^8.5|^9.6",
+                "rector/rector": "^2.0",
+                "vimeo/psalm": "^4.3 || ^5.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Jean85\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Alessandro Lai",
+                    "email": "alessandro.lai85@gmail.com"
+                }
+            ],
+            "description": "A library to get pretty versions strings of installed dependencies",
+            "keywords": [
+                "composer",
+                "package",
+                "release",
+                "versions"
+            ],
+            "support": {
+                "issues": "https://github.com/Jean85/pretty-package-versions/issues",
+                "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
+            },
+            "time": "2025-03-19T14:43:43+00:00"
+        },
+        {
+            "name": "myclabs/deep-copy",
+            "version": "1.13.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/myclabs/DeepCopy.git",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "doctrine/collections": "<1.6.8",
+                "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+            },
+            "require-dev": {
+                "doctrine/collections": "^1.6.8",
+                "doctrine/common": "^2.13.3 || ^3.2.2",
+                "phpspec/prophecy": "^1.10",
+                "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/DeepCopy/deep_copy.php"
+                ],
+                "psr-4": {
+                    "DeepCopy\\": "src/DeepCopy/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Create deep copies (clones) of your objects",
+            "keywords": [
+                "clone",
+                "copy",
+                "duplicate",
+                "object",
+                "object graph"
+            ],
+            "support": {
+                "issues": "https://github.com/myclabs/DeepCopy/issues",
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+            },
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-01T08:46:24+00:00"
+        },
+        {
+            "name": "nikic/php-parser",
+            "version": "v5.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/PHP-Parser.git",
+                "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+                "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "ext-tokenizer": "*",
+                "php": ">=7.4"
+            },
+            "require-dev": {
+                "ircmaxell/php-yacc": "^0.0.7",
+                "phpunit/phpunit": "^9.0"
+            },
+            "bin": [
+                "bin/php-parse"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpParser\\": "lib/PhpParser"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov"
+                }
+            ],
+            "description": "A PHP parser written in PHP",
+            "keywords": [
+                "parser",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/PHP-Parser/issues",
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
+            },
+            "time": "2026-07-04T14:30:18+00:00"
+        },
+        {
+            "name": "nunomaduro/collision",
+            "version": "v8.5.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nunomaduro/collision.git",
+                "reference": "f5c101b929c958e849a633283adff296ed5f38f5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f5c101b929c958e849a633283adff296ed5f38f5",
+                "reference": "f5c101b929c958e849a633283adff296ed5f38f5",
+                "shasum": ""
+            },
+            "require": {
+                "filp/whoops": "^2.16.0",
+                "nunomaduro/termwind": "^2.1.0",
+                "php": "^8.2.0",
+                "symfony/console": "^7.1.5"
+            },
+            "conflict": {
+                "laravel/framework": "<11.0.0 || >=12.0.0",
+                "phpunit/phpunit": "<10.5.1 || >=12.0.0"
+            },
+            "require-dev": {
+                "larastan/larastan": "^2.9.8",
+                "laravel/framework": "^11.28.0",
+                "laravel/pint": "^1.18.1",
+                "laravel/sail": "^1.36.0",
+                "laravel/sanctum": "^4.0.3",
+                "laravel/tinker": "^2.10.0",
+                "orchestra/testbench-core": "^9.5.3",
+                "pestphp/pest": "^2.36.0 || ^3.4.0",
+                "sebastian/environment": "^6.1.0 || ^7.2.0"
+            },
+            "type": "library",
+            "extra": {
+                "laravel": {
+                    "providers": [
+                        "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider"
+                    ]
+                },
+                "branch-alias": {
+                    "dev-8.x": "8.x-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "./src/Adapters/Phpunit/Autoload.php"
+                ],
+                "psr-4": {
+                    "NunoMaduro\\Collision\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nuno Maduro",
+                    "email": "enunomaduro@gmail.com"
+                }
+            ],
+            "description": "Cli error handling for console/command-line PHP applications.",
+            "keywords": [
+                "artisan",
+                "cli",
+                "command-line",
+                "console",
+                "error",
+                "handling",
+                "laravel",
+                "laravel-zero",
+                "php",
+                "symfony"
+            ],
+            "support": {
+                "issues": "https://github.com/nunomaduro/collision/issues",
+                "source": "https://github.com/nunomaduro/collision"
+            },
+            "funding": [
+                {
+                    "url": "https://www.paypal.com/paypalme/enunomaduro",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/nunomaduro",
+                    "type": "github"
+                },
+                {
+                    "url": "https://www.patreon.com/nunomaduro",
+                    "type": "patreon"
+                }
+            ],
+            "time": "2024-10-15T16:06:32+00:00"
+        },
+        {
+            "name": "nunomaduro/termwind",
+            "version": "v2.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nunomaduro/termwind.git",
+                "reference": "712a31b768f5daea284c2169a7d227031001b9a8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8",
+                "reference": "712a31b768f5daea284c2169a7d227031001b9a8",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "php": "^8.2",
+                "symfony/console": "^7.4.4 || ^8.0.4"
+            },
+            "require-dev": {
+                "illuminate/console": "^11.47.0",
+                "laravel/pint": "^1.27.1",
+                "mockery/mockery": "^1.6.12",
+                "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2",
+                "phpstan/phpstan": "^1.12.32",
+                "phpstan/phpstan-strict-rules": "^1.6.2",
+                "symfony/var-dumper": "^7.3.5 || ^8.0.4",
+                "thecodingmachine/phpstan-strict-rules": "^1.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "laravel": {
+                    "providers": [
+                        "Termwind\\Laravel\\TermwindServiceProvider"
+                    ]
+                },
+                "branch-alias": {
+                    "dev-2.x": "2.x-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/Functions.php"
+                ],
+                "psr-4": {
+                    "Termwind\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nuno Maduro",
+                    "email": "enunomaduro@gmail.com"
+                }
+            ],
+            "description": "It's like Tailwind CSS, but for the console.",
+            "keywords": [
+                "cli",
+                "console",
+                "css",
+                "package",
+                "php",
+                "style"
+            ],
+            "support": {
+                "issues": "https://github.com/nunomaduro/termwind/issues",
+                "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://www.paypal.com/paypalme/enunomaduro",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/nunomaduro",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/xiCO2k",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-02-16T23:10:27+00:00"
+        },
+        {
+            "name": "pestphp/pest",
+            "version": "v2.36.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/pestphp/pest.git",
+                "reference": "d66361b272ae4ee4bc33accb5ea3ff385b92e9e1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/pestphp/pest/zipball/d66361b272ae4ee4bc33accb5ea3ff385b92e9e1",
+                "reference": "d66361b272ae4ee4bc33accb5ea3ff385b92e9e1",
+                "shasum": ""
+            },
+            "require": {
+                "brianium/paratest": "^7.4.9",
+                "nunomaduro/collision": "^7.11.0|^8.5.0",
+                "nunomaduro/termwind": "^1.16.0|^2.3.3",
+                "pestphp/pest-plugin": "^2.1.1",
+                "pestphp/pest-plugin-arch": "^2.7.0",
+                "php": "^8.2.0",
+                "phpunit/phpunit": "^10.5.63"
+            },
+            "conflict": {
+                "filp/whoops": "<2.16.0",
+                "phpunit/phpunit": ">10.5.63",
+                "sebastian/exporter": "<5.1.0",
+                "webmozart/assert": "<1.11.0"
+            },
+            "require-dev": {
+                "pestphp/pest-dev-tools": "^2.17.0",
+                "pestphp/pest-plugin-type-coverage": "^2.8.7",
+                "symfony/process": "^6.4.0|^7.4.4"
+            },
+            "bin": [
+                "bin/pest"
+            ],
+            "type": "library",
+            "extra": {
+                "pest": {
+                    "plugins": [
+                        "Pest\\Plugins\\Bail",
+                        "Pest\\Plugins\\Cache",
+                        "Pest\\Plugins\\Coverage",
+                        "Pest\\Plugins\\Init",
+                        "Pest\\Plugins\\Environment",
+                        "Pest\\Plugins\\Help",
+                        "Pest\\Plugins\\Memory",
+                        "Pest\\Plugins\\Only",
+                        "Pest\\Plugins\\Printer",
+                        "Pest\\Plugins\\ProcessIsolation",
+                        "Pest\\Plugins\\Profile",
+                        "Pest\\Plugins\\Retry",
+                        "Pest\\Plugins\\Snapshot",
+                        "Pest\\Plugins\\Verbose",
+                        "Pest\\Plugins\\Version",
+                        "Pest\\Plugins\\Parallel"
+                    ]
+                },
+                "phpstan": {
+                    "includes": [
+                        "extension.neon"
+                    ]
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/Functions.php",
+                    "src/Pest.php"
+                ],
+                "psr-4": {
+                    "Pest\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nuno Maduro",
+                    "email": "enunomaduro@gmail.com"
+                }
+            ],
+            "description": "The elegant PHP Testing Framework.",
+            "keywords": [
+                "framework",
+                "pest",
+                "php",
+                "test",
+                "testing",
+                "unit"
+            ],
+            "support": {
+                "issues": "https://github.com/pestphp/pest/issues",
+                "source": "https://github.com/pestphp/pest/tree/v2.36.1"
+            },
+            "funding": [
+                {
+                    "url": "https://www.paypal.com/paypalme/enunomaduro",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/nunomaduro",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-01-28T02:02:41+00:00"
+        },
+        {
+            "name": "pestphp/pest-plugin",
+            "version": "v2.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/pestphp/pest-plugin.git",
+                "reference": "e05d2859e08c2567ee38ce8b005d044e72648c0b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/e05d2859e08c2567ee38ce8b005d044e72648c0b",
+                "reference": "e05d2859e08c2567ee38ce8b005d044e72648c0b",
+                "shasum": ""
+            },
+            "require": {
+                "composer-plugin-api": "^2.0.0",
+                "composer-runtime-api": "^2.2.2",
+                "php": "^8.1"
+            },
+            "conflict": {
+                "pestphp/pest": "<2.2.3"
+            },
+            "require-dev": {
+                "composer/composer": "^2.5.8",
+                "pestphp/pest": "^2.16.0",
+                "pestphp/pest-dev-tools": "^2.16.0"
+            },
+            "type": "composer-plugin",
+            "extra": {
+                "class": "Pest\\Plugin\\Manager"
+            },
+            "autoload": {
+                "psr-4": {
+                    "Pest\\Plugin\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "The Pest plugin manager",
+            "keywords": [
+                "framework",
+                "manager",
+                "pest",
+                "php",
+                "plugin",
+                "test",
+                "testing",
+                "unit"
+            ],
+            "support": {
+                "source": "https://github.com/pestphp/pest-plugin/tree/v2.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/nunomaduro",
+                    "type": "github"
+                },
+                {
+                    "url": "https://www.patreon.com/nunomaduro",
+                    "type": "patreon"
+                }
+            ],
+            "time": "2023-08-22T08:40:06+00:00"
+        },
+        {
+            "name": "pestphp/pest-plugin-arch",
+            "version": "v2.7.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/pestphp/pest-plugin-arch.git",
+                "reference": "d23b2d7498475354522c3818c42ef355dca3fcda"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/d23b2d7498475354522c3818c42ef355dca3fcda",
+                "reference": "d23b2d7498475354522c3818c42ef355dca3fcda",
+                "shasum": ""
+            },
+            "require": {
+                "nunomaduro/collision": "^7.10.0|^8.1.0",
+                "pestphp/pest-plugin": "^2.1.1",
+                "php": "^8.1",
+                "ta-tikoma/phpunit-architecture-test": "^0.8.4"
+            },
+            "require-dev": {
+                "pestphp/pest": "^2.33.0",
+                "pestphp/pest-dev-tools": "^2.16.0"
+            },
+            "type": "library",
+            "extra": {
+                "pest": {
+                    "plugins": [
+                        "Pest\\Arch\\Plugin"
+                    ]
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/Autoload.php"
+                ],
+                "psr-4": {
+                    "Pest\\Arch\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "The Arch plugin for Pest PHP.",
+            "keywords": [
+                "arch",
+                "architecture",
+                "framework",
+                "pest",
+                "php",
+                "plugin",
+                "test",
+                "testing",
+                "unit"
+            ],
+            "support": {
+                "source": "https://github.com/pestphp/pest-plugin-arch/tree/v2.7.0"
+            },
+            "funding": [
+                {
+                    "url": "https://www.paypal.com/paypalme/enunomaduro",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/nunomaduro",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-01-26T09:46:42+00:00"
+        },
+        {
+            "name": "pestphp/pest-plugin-drift",
+            "version": "v2.6.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/pestphp/pest-plugin-drift.git",
+                "reference": "ed78e637a7f7b77c9b33b02ffba40555e74001ef"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/pestphp/pest-plugin-drift/zipball/ed78e637a7f7b77c9b33b02ffba40555e74001ef",
+                "reference": "ed78e637a7f7b77c9b33b02ffba40555e74001ef",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^5.0.2",
+                "pestphp/pest": "^2.34.4",
+                "php": "^8.2.0",
+                "symfony/finder": "^7.0.0"
+            },
+            "require-dev": {
+                "pestphp/pest-dev-tools": "^2.16.0"
+            },
+            "type": "library",
+            "extra": {
+                "pest": {
+                    "plugins": [
+                        "Pest\\Drift\\Plugin"
+                    ]
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Pest\\Drift\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "The Pest Drift Plugin",
+            "keywords": [
+                "framework",
+                "laravel",
+                "pest",
+                "php",
+                "test",
+                "testing",
+                "unit"
+            ],
+            "support": {
+                "issues": "https://github.com/pestphp/pest-plugin-drift/issues",
+                "source": "https://github.com/pestphp/pest-plugin-drift/tree/v2.6.1"
+            },
+            "funding": [
+                {
+                    "url": "https://www.paypal.com/paypalme/enunomaduro",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/mandisma",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nunomaduro",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-05-05T07:36:43+00:00"
+        },
+        {
+            "name": "phar-io/manifest",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/manifest.git",
+                "reference": "54750ef60c58e43759730615a392c31c80e23176"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+                "reference": "54750ef60c58e43759730615a392c31c80e23176",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-phar": "*",
+                "ext-xmlwriter": "*",
+                "phar-io/version": "^3.0.1",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+            "support": {
+                "issues": "https://github.com/phar-io/manifest/issues",
+                "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-03T12:33:53+00:00"
+        },
+        {
+            "name": "phar-io/version",
+            "version": "3.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/version.git",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Library for handling version information and constraints",
+            "support": {
+                "issues": "https://github.com/phar-io/version/issues",
+                "source": "https://github.com/phar-io/version/tree/3.2.1"
+            },
+            "time": "2022-02-21T01:04:05+00:00"
+        },
+        {
+            "name": "phpdocumentor/reflection-common",
+            "version": "2.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+                "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+                "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-2.x": "2.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "phpDocumentor\\Reflection\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jaap van Otterdijk",
+                    "email": "opensource@ijaap.nl"
+                }
+            ],
+            "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+            "homepage": "http://www.phpdoc.org",
+            "keywords": [
+                "FQSEN",
+                "phpDocumentor",
+                "phpdoc",
+                "reflection",
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+                "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+            },
+            "time": "2020-06-27T09:03:43+00:00"
+        },
+        {
+            "name": "phpdocumentor/reflection-docblock",
+            "version": "6.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+                "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582",
+                "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/deprecations": "^1.1",
+                "ext-filter": "*",
+                "php": "^7.4 || ^8.0",
+                "phpdocumentor/reflection-common": "^2.2",
+                "phpdocumentor/type-resolver": "^2.0",
+                "phpstan/phpdoc-parser": "^2.0",
+                "webmozart/assert": "^1.9.1 || ^2"
+            },
+            "require-dev": {
+                "mockery/mockery": "~1.3.5 || ~1.6.0",
+                "phpstan/extension-installer": "^1.1",
+                "phpstan/phpstan": "^1.8",
+                "phpstan/phpstan-mockery": "^1.1",
+                "phpstan/phpstan-webmozart-assert": "^1.2",
+                "phpunit/phpunit": "^9.5",
+                "psalm/phar": "^5.26",
+                "shipmonk/dead-code-detector": "^0.5.1"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "phpDocumentor\\Reflection\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Mike van Riel",
+                    "email": "me@mikevanriel.com"
+                },
+                {
+                    "name": "Jaap van Otterdijk",
+                    "email": "opensource@ijaap.nl"
+                }
+            ],
+            "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+            "support": {
+                "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+                "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3"
+            },
+            "time": "2026-03-18T20:49:53+00:00"
+        },
+        {
+            "name": "phpdocumentor/type-resolver",
+            "version": "2.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpDocumentor/TypeResolver.git",
+                "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
+                "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/deprecations": "^1.0",
+                "php": "^7.4 || ^8.0",
+                "phpdocumentor/reflection-common": "^2.0",
+                "phpstan/phpdoc-parser": "^2.0"
+            },
+            "require-dev": {
+                "ext-tokenizer": "*",
+                "phpbench/phpbench": "^1.2",
+                "phpstan/extension-installer": "^1.4",
+                "phpstan/phpstan": "^2.1",
+                "phpstan/phpstan-phpunit": "^2.0",
+                "phpunit/phpunit": "^9.5",
+                "psalm/phar": "^4"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-1.x": "1.x-dev",
+                    "dev-2.x": "2.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "phpDocumentor\\Reflection\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Mike van Riel",
+                    "email": "me@mikevanriel.com"
+                }
+            ],
+            "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+            "support": {
+                "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+                "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
+            },
+            "time": "2026-01-06T21:53:42+00:00"
+        },
+        {
+            "name": "phpstan/phpdoc-parser",
+            "version": "2.3.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpstan/phpdoc-parser.git",
+                "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
+                "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.4 || ^8.0"
+            },
+            "require-dev": {
+                "doctrine/annotations": "^2.0",
+                "nikic/php-parser": "^5.3.0",
+                "php-parallel-lint/php-parallel-lint": "^1.2",
+                "phpstan/extension-installer": "^1.0",
+                "phpstan/phpstan": "^2.0",
+                "phpstan/phpstan-phpunit": "^2.0",
+                "phpstan/phpstan-strict-rules": "^2.0",
+                "phpunit/phpunit": "^9.6",
+                "symfony/process": "^5.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "PHPStan\\PhpDocParser\\": [
+                        "src/"
+                    ]
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "PHPDoc parser with support for nullable, intersection and generic types",
+            "support": {
+                "issues": "https://github.com/phpstan/phpdoc-parser/issues",
+                "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3"
+            },
+            "time": "2026-07-08T07:01:06+00:00"
+        },
+        {
+            "name": "phpstan/phpstan",
+            "version": "2.2.5",
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
+                "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.4|^8.0"
+            },
+            "conflict": {
+                "phpstan/phpstan-shim": "*"
+            },
+            "bin": [
+                "phpstan",
+                "phpstan.phar"
+            ],
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ondřej Mirtes"
+                },
+                {
+                    "name": "Markus Staab"
+                },
+                {
+                    "name": "Vincent Langlet"
+                }
+            ],
+            "description": "PHPStan - PHP Static Analysis Tool",
+            "keywords": [
+                "dev",
+                "static analysis"
+            ],
+            "support": {
+                "docs": "https://phpstan.org/user-guide/getting-started",
+                "forum": "https://github.com/phpstan/phpstan/discussions",
+                "issues": "https://github.com/phpstan/phpstan/issues",
+                "security": "https://github.com/phpstan/phpstan/security/policy",
+                "source": "https://github.com/phpstan/phpstan-src"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/ondrejmirtes",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/phpstan",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-07-05T06:31:06+00:00"
+        },
+        {
+            "name": "phpunit/php-code-coverage",
+            "version": "10.1.16",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+                "reference": "7e308268858ed6baedc8704a304727d20bc07c77"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77",
+                "reference": "7e308268858ed6baedc8704a304727d20bc07c77",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-xmlwriter": "*",
+                "nikic/php-parser": "^4.19.1 || ^5.1.0",
+                "php": ">=8.1",
+                "phpunit/php-file-iterator": "^4.1.0",
+                "phpunit/php-text-template": "^3.0.1",
+                "sebastian/code-unit-reverse-lookup": "^3.0.0",
+                "sebastian/complexity": "^3.2.0",
+                "sebastian/environment": "^6.1.0",
+                "sebastian/lines-of-code": "^2.0.2",
+                "sebastian/version": "^4.0.1",
+                "theseer/tokenizer": "^1.2.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.1"
+            },
+            "suggest": {
+                "ext-pcov": "PHP extension that provides line coverage",
+                "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "10.1.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+            "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+            "keywords": [
+                "coverage",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+                "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-08-22T04:31:57+00:00"
+        },
+        {
+            "name": "phpunit/php-file-iterator",
+            "version": "4.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+                "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c",
+                "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+            "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+            "keywords": [
+                "filesystem",
+                "iterator"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+                "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-08-31T06:24:48+00:00"
+        },
+        {
+            "name": "phpunit/php-invoker",
+            "version": "4.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-invoker.git",
+                "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
+                "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "ext-pcntl": "*",
+                "phpunit/phpunit": "^10.0"
+            },
+            "suggest": {
+                "ext-pcntl": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Invoke callables with a timeout",
+            "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+            "keywords": [
+                "process"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+                "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T06:56:09+00:00"
+        },
+        {
+            "name": "phpunit/php-text-template",
+            "version": "3.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-text-template.git",
+                "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748",
+                "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Simple template engine.",
+            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+            "keywords": [
+                "template"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+                "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-08-31T14:07:24+00:00"
+        },
+        {
+            "name": "phpunit/php-timer",
+            "version": "6.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-timer.git",
+                "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d",
+                "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Utility class for timing",
+            "homepage": "https://github.com/sebastianbergmann/php-timer/",
+            "keywords": [
+                "timer"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+                "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T06:57:52+00:00"
+        },
+        {
+            "name": "phpunit/phpunit",
+            "version": "10.5.63",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/phpunit.git",
+                "reference": "33198268dad71e926626b618f3ec3966661e4d90"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90",
+                "reference": "33198268dad71e926626b618f3ec3966661e4d90",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-json": "*",
+                "ext-libxml": "*",
+                "ext-mbstring": "*",
+                "ext-xml": "*",
+                "ext-xmlwriter": "*",
+                "myclabs/deep-copy": "^1.13.4",
+                "phar-io/manifest": "^2.0.4",
+                "phar-io/version": "^3.2.1",
+                "php": ">=8.1",
+                "phpunit/php-code-coverage": "^10.1.16",
+                "phpunit/php-file-iterator": "^4.1.0",
+                "phpunit/php-invoker": "^4.0.0",
+                "phpunit/php-text-template": "^3.0.1",
+                "phpunit/php-timer": "^6.0.0",
+                "sebastian/cli-parser": "^2.0.1",
+                "sebastian/code-unit": "^2.0.0",
+                "sebastian/comparator": "^5.0.5",
+                "sebastian/diff": "^5.1.1",
+                "sebastian/environment": "^6.1.0",
+                "sebastian/exporter": "^5.1.4",
+                "sebastian/global-state": "^6.0.2",
+                "sebastian/object-enumerator": "^5.0.0",
+                "sebastian/recursion-context": "^5.0.1",
+                "sebastian/type": "^4.0.0",
+                "sebastian/version": "^4.0.1"
+            },
+            "suggest": {
+                "ext-soap": "To be able to generate mocks based on WSDL files"
+            },
+            "bin": [
+                "phpunit"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "10.5-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/Framework/Assert/Functions.php"
+                ],
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "The PHP Unit Testing framework.",
+            "homepage": "https://phpunit.de/",
+            "keywords": [
+                "phpunit",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+                "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63"
+            },
+            "funding": [
+                {
+                    "url": "https://phpunit.de/sponsors.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-27T05:48:37+00:00"
+        },
+        {
+            "name": "psr/container",
+            "version": "2.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/container.git",
+                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.4.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common Container Interface (PHP FIG PSR-11)",
+            "homepage": "https://github.com/php-fig/container",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interface",
+                "container-interop",
+                "psr"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/container/issues",
+                "source": "https://github.com/php-fig/container/tree/2.0.2"
+            },
+            "time": "2021-11-05T16:47:00+00:00"
+        },
+        {
+            "name": "psr/event-dispatcher",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/event-dispatcher.git",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\EventDispatcher\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Standard interfaces for event handling.",
+            "keywords": [
+                "events",
+                "psr",
+                "psr-14"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/event-dispatcher/issues",
+                "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+            },
+            "time": "2019-01-08T18:20:26+00:00"
+        },
+        {
+            "name": "psr/log",
+            "version": "3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/log.git",
+                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Log\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for logging libraries",
+            "homepage": "https://github.com/php-fig/log",
+            "keywords": [
+                "log",
+                "psr",
+                "psr-3"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/log/tree/3.0.2"
+            },
+            "time": "2024-09-11T13:17:53+00:00"
+        },
+        {
+            "name": "react/cache",
+            "version": "v1.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/cache.git",
+                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
+                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0",
+                "react/promise": "^3.0 || ^2.0 || ^1.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Cache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async, Promise-based cache interface for ReactPHP",
+            "keywords": [
+                "cache",
+                "caching",
+                "promise",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/cache/issues",
+                "source": "https://github.com/reactphp/cache/tree/v1.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2022-11-30T15:59:55+00:00"
+        },
+        {
+            "name": "react/child-process",
+            "version": "v0.6.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/child-process.git",
+                "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3",
+                "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.0",
+                "react/event-loop": "^1.2",
+                "react/stream": "^1.4"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/socket": "^1.16",
+                "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\ChildProcess\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Event-driven library for executing child processes with ReactPHP.",
+            "keywords": [
+                "event-driven",
+                "process",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/child-process/issues",
+                "source": "https://github.com/reactphp/child-process/tree/v0.6.7"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-12-23T15:25:20+00:00"
+        },
+        {
+            "name": "react/dns",
+            "version": "v1.14.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/dns.git",
+                "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3",
+                "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0",
+                "react/cache": "^1.0 || ^0.6 || ^0.5",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.7 || ^1.2.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/async": "^4.3 || ^3 || ^2",
+                "react/promise-timer": "^1.11"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Dns\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async DNS resolver for ReactPHP",
+            "keywords": [
+                "async",
+                "dns",
+                "dns-resolver",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/dns/issues",
+                "source": "https://github.com/reactphp/dns/tree/v1.14.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-11-18T19:34:28+00:00"
+        },
+        {
+            "name": "react/event-loop",
+            "version": "v1.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/event-loop.git",
+                "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
+                "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "suggest": {
+                "ext-pcntl": "For signal handling support when using the StreamSelectLoop"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\EventLoop\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
+            "keywords": [
+                "asynchronous",
+                "event-loop"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/event-loop/issues",
+                "source": "https://github.com/reactphp/event-loop/tree/v1.6.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-11-17T20:46:25+00:00"
+        },
+        {
+            "name": "react/promise",
+            "version": "v3.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/promise.git",
+                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
+                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "1.12.28 || 1.4.10",
+                "phpunit/phpunit": "^9.6 || ^7.5"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions_include.php"
+                ],
+                "psr-4": {
+                    "React\\Promise\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "A lightweight implementation of CommonJS Promises/A for PHP",
+            "keywords": [
+                "promise",
+                "promises"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/promise/issues",
+                "source": "https://github.com/reactphp/promise/tree/v3.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-08-19T18:57:03+00:00"
+        },
+        {
+            "name": "react/socket",
+            "version": "v1.17.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/socket.git",
+                "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08",
+                "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.0",
+                "react/dns": "^1.13",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.6 || ^1.2.1",
+                "react/stream": "^1.4"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/async": "^4.3 || ^3.3 || ^2",
+                "react/promise-stream": "^1.4",
+                "react/promise-timer": "^1.11"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Socket\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
+            "keywords": [
+                "Connection",
+                "Socket",
+                "async",
+                "reactphp",
+                "stream"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/socket/issues",
+                "source": "https://github.com/reactphp/socket/tree/v1.17.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-11-19T20:47:34+00:00"
+        },
+        {
+            "name": "react/stream",
+            "version": "v1.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/stream.git",
+                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.8",
+                "react/event-loop": "^1.2"
+            },
+            "require-dev": {
+                "clue/stream-filter": "~1.2",
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Stream\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
+            "keywords": [
+                "event-driven",
+                "io",
+                "non-blocking",
+                "pipe",
+                "reactphp",
+                "readable",
+                "stream",
+                "writable"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/stream/issues",
+                "source": "https://github.com/reactphp/stream/tree/v1.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2024-06-11T12:45:25+00:00"
+        },
+        {
+            "name": "sebastian/cli-parser",
+            "version": "2.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/cli-parser.git",
+                "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084",
+                "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for parsing CLI options",
+            "homepage": "https://github.com/sebastianbergmann/cli-parser",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+                "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+                "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-02T07:12:49+00:00"
+        },
+        {
+            "name": "sebastian/code-unit",
+            "version": "2.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit.git",
+                "reference": "a81fee9eef0b7a76af11d121767abc44c104e503"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503",
+                "reference": "a81fee9eef0b7a76af11d121767abc44c104e503",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/code-unit",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T06:58:43+00:00"
+        },
+        {
+            "name": "sebastian/code-unit-reverse-lookup",
+            "version": "3.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+                "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
+                "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Looks up which function or method a line of code belongs to",
+            "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T06:59:15+00:00"
+        },
+        {
+            "name": "sebastian/comparator",
+            "version": "5.0.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/comparator.git",
+                "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
+                "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-mbstring": "*",
+                "php": ">=8.1",
+                "sebastian/diff": "^5.0",
+                "sebastian/exporter": "^5.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@2bepublished.at"
+                }
+            ],
+            "description": "Provides the functionality to compare PHP values for equality",
+            "homepage": "https://github.com/sebastianbergmann/comparator",
+            "keywords": [
+                "comparator",
+                "compare",
+                "equality"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/comparator/issues",
+                "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+                "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-24T09:25:16+00:00"
+        },
+        {
+            "name": "sebastian/complexity",
+            "version": "3.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/complexity.git",
+                "reference": "68ff824baeae169ec9f2137158ee529584553799"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799",
+                "reference": "68ff824baeae169ec9f2137158ee529584553799",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.18 || ^5.0",
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for calculating the complexity of PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/complexity",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/complexity/issues",
+                "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+                "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-12-21T08:37:17+00:00"
+        },
+        {
+            "name": "sebastian/diff",
+            "version": "5.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/diff.git",
+                "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e",
+                "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0",
+                "symfony/process": "^6.4"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Kore Nordmann",
+                    "email": "mail@kore-nordmann.de"
+                }
+            ],
+            "description": "Diff implementation",
+            "homepage": "https://github.com/sebastianbergmann/diff",
+            "keywords": [
+                "diff",
+                "udiff",
+                "unidiff",
+                "unified diff"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/diff/issues",
+                "security": "https://github.com/sebastianbergmann/diff/security/policy",
+                "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-02T07:15:17+00:00"
+        },
+        {
+            "name": "sebastian/environment",
+            "version": "6.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/environment.git",
+                "reference": "8074dbcd93529b357029f5cc5058fd3e43666984"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984",
+                "reference": "8074dbcd93529b357029f5cc5058fd3e43666984",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "suggest": {
+                "ext-posix": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides functionality to handle HHVM/PHP environments",
+            "homepage": "https://github.com/sebastianbergmann/environment",
+            "keywords": [
+                "Xdebug",
+                "environment",
+                "hhvm"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/environment/issues",
+                "security": "https://github.com/sebastianbergmann/environment/security/policy",
+                "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-23T08:47:14+00:00"
+        },
+        {
+            "name": "sebastian/exporter",
+            "version": "5.1.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/exporter.git",
+                "reference": "0735b90f4da94969541dac1da743446e276defa6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6",
+                "reference": "0735b90f4da94969541dac1da743446e276defa6",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "php": ">=8.1",
+                "sebastian/recursion-context": "^5.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                }
+            ],
+            "description": "Provides the functionality to export PHP variables for visualization",
+            "homepage": "https://www.github.com/sebastianbergmann/exporter",
+            "keywords": [
+                "export",
+                "exporter"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/exporter/issues",
+                "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+                "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-09-24T06:09:11+00:00"
+        },
+        {
+            "name": "sebastian/global-state",
+            "version": "6.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/global-state.git",
+                "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
+                "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "sebastian/object-reflector": "^3.0",
+                "sebastian/recursion-context": "^5.0"
+            },
+            "require-dev": {
+                "ext-dom": "*",
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Snapshotting of global state",
+            "homepage": "https://www.github.com/sebastianbergmann/global-state",
+            "keywords": [
+                "global state"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/global-state/issues",
+                "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+                "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-02T07:19:19+00:00"
+        },
+        {
+            "name": "sebastian/lines-of-code",
+            "version": "2.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+                "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0",
+                "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.18 || ^5.0",
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for counting the lines of code in PHP source code",
+            "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+                "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+                "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-12-21T08:38:20+00:00"
+        },
+        {
+            "name": "sebastian/object-enumerator",
+            "version": "5.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+                "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906",
+                "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "sebastian/object-reflector": "^3.0",
+                "sebastian/recursion-context": "^5.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+            "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T07:08:32+00:00"
+        },
+        {
+            "name": "sebastian/object-reflector",
+            "version": "3.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-reflector.git",
+                "reference": "24ed13d98130f0e7122df55d06c5c4942a577957"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957",
+                "reference": "24ed13d98130f0e7122df55d06c5c4942a577957",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Allows reflection of object attributes, including inherited and non-public ones",
+            "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+                "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T07:06:18+00:00"
+        },
+        {
+            "name": "sebastian/recursion-context",
+            "version": "5.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/recursion-context.git",
+                "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a",
+                "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                }
+            ],
+            "description": "Provides functionality to recursively process PHP variables",
+            "homepage": "https://github.com/sebastianbergmann/recursion-context",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+                "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-10T07:50:56+00:00"
+        },
+        {
+            "name": "sebastian/type",
+            "version": "4.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/type.git",
+                "reference": "462699a16464c3944eefc02ebdd77882bd3925bf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf",
+                "reference": "462699a16464c3944eefc02ebdd77882bd3925bf",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^10.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the types of the PHP type system",
+            "homepage": "https://github.com/sebastianbergmann/type",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/type/issues",
+                "source": "https://github.com/sebastianbergmann/type/tree/4.0.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T07:10:45+00:00"
+        },
+        {
+            "name": "sebastian/version",
+            "version": "4.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/version.git",
+                "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17",
+                "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+            "homepage": "https://github.com/sebastianbergmann/version",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/version/issues",
+                "source": "https://github.com/sebastianbergmann/version/tree/4.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-07T11:34:05+00:00"
+        },
+        {
+            "name": "symfony/console",
+            "version": "v7.4.14",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/console.git",
+                "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87",
+                "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/service-contracts": "^2.5|^3",
+                "symfony/string": "^7.2|^8.0"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<6.4",
+                "symfony/dotenv": "<6.4",
+                "symfony/event-dispatcher": "<6.4",
+                "symfony/lock": "<6.4",
+                "symfony/process": "<6.4"
+            },
+            "provide": {
+                "psr/log-implementation": "1.0|2.0|3.0"
+            },
+            "require-dev": {
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^6.4|^7.0|^8.0",
+                "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+                "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+                "symfony/http-foundation": "^6.4|^7.0|^8.0",
+                "symfony/http-kernel": "^6.4|^7.0|^8.0",
+                "symfony/lock": "^6.4|^7.0|^8.0",
+                "symfony/messenger": "^6.4|^7.0|^8.0",
+                "symfony/process": "^6.4|^7.0|^8.0",
+                "symfony/stopwatch": "^6.4|^7.0|^8.0",
+                "symfony/var-dumper": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Console\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Eases the creation of beautiful and testable command line interfaces",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "cli",
+                "command-line",
+                "console",
+                "terminal"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/console/tree/v7.4.14"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-06-16T11:50:14+00:00"
+        },
+        {
+            "name": "symfony/deprecation-contracts",
+            "version": "v3.7.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/deprecation-contracts.git",
+                "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
+                "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "3.7-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "function.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "A generic function and convention to trigger deprecation notices",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-06-05T06:23:12+00:00"
+        },
+        {
+            "name": "symfony/event-dispatcher",
+            "version": "v8.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/event-dispatcher.git",
+                "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0",
+                "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.4.1",
+                "symfony/deprecation-contracts": "^2.5|^3",
+                "symfony/event-dispatcher-contracts": "^2.5|^3"
+            },
+            "conflict": {
+                "symfony/security-http": "<7.4",
+                "symfony/service-contracts": "<2.5"
+            },
+            "provide": {
+                "psr/event-dispatcher-implementation": "1.0",
+                "symfony/event-dispatcher-implementation": "2.0|3.0"
+            },
+            "require-dev": {
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^7.4|^8.0",
+                "symfony/dependency-injection": "^7.4|^8.0",
+                "symfony/error-handler": "^7.4|^8.0",
+                "symfony/expression-language": "^7.4|^8.0",
+                "symfony/framework-bundle": "^7.4|^8.0",
+                "symfony/http-foundation": "^7.4|^8.0",
+                "symfony/service-contracts": "^2.5|^3",
+                "symfony/stopwatch": "^7.4|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\EventDispatcher\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-06-09T12:28:30+00:00"
+        },
+        {
+            "name": "symfony/event-dispatcher-contracts",
+            "version": "v3.7.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/event-dispatcher-contracts.git",
+                "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e",
+                "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/event-dispatcher": "^1"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "3.7-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\EventDispatcher\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to dispatching event",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-06-05T06:23:12+00:00"
+        },
+        {
+            "name": "symfony/filesystem",
+            "version": "v8.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/filesystem.git",
+                "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2",
+                "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.4.1",
+                "symfony/deprecation-contracts": "^2.5|^3",
+                "symfony/polyfill-ctype": "~1.8",
+                "symfony/polyfill-mbstring": "~1.8"
+            },
+            "require-dev": {
+                "symfony/process": "^7.4|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Filesystem\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides basic utilities for the filesystem",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/filesystem/tree/v8.1.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-29T05:06:50+00:00"
+        },
+        {
+            "name": "symfony/finder",
+            "version": "v7.4.14",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/finder.git",
+                "reference": "13b38720174286f55d1761152b575a8d1436fc25"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25",
+                "reference": "13b38720174286f55d1761152b575a8d1436fc25",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "symfony/filesystem": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Finder\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Finds files and directories via an intuitive fluent interface",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/finder/tree/v7.4.14"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-06-27T08:31:18+00:00"
+        },
+        {
+            "name": "symfony/options-resolver",
+            "version": "v8.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/options-resolver.git",
+                "reference": "88f9c561f678a02d54b897014049fa839e33ff82"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82",
+                "reference": "88f9c561f678a02d54b897014049fa839e33ff82",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.4.1",
+                "symfony/deprecation-contracts": "^2.5|^3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\OptionsResolver\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides an improved replacement for the array_replace PHP function",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "config",
+                "configuration",
+                "options"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/options-resolver/tree/v8.1.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-29T05:06:50+00:00"
+        },
+        {
+            "name": "symfony/polyfill-ctype",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-ctype.git",
+                "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+                "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-ctype": "*"
+            },
+            "suggest": {
+                "ext-ctype": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Ctype\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Gert de Pagter",
+                    "email": "BackEndTea@gmail.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for ctype functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "ctype",
+                "polyfill",
+                "portable"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T16:19:22+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-grapheme",
+            "version": "v1.38.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+                "reference": "e9247d281d694a5120554d9afaf54e070e88a603"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603",
+                "reference": "e9247d281d694a5120554d9afaf54e070e88a603",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's grapheme_* functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "grapheme",
+                "intl",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-26T05:58:03+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-normalizer",
+            "version": "v1.38.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+                "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+                "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's Normalizer class and related functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "intl",
+                "normalizer",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-25T13:48:31+00:00"
+        },
+        {
+            "name": "symfony/polyfill-mbstring",
+            "version": "v1.38.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-mbstring.git",
+                "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
+                "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
+                "shasum": ""
+            },
+            "require": {
+                "ext-iconv": "*",
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-mbstring": "*"
+            },
+            "suggest": {
+                "ext-mbstring": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Mbstring\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for the Mbstring extension",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "mbstring",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-27T06:59:30+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php80",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php80.git",
+                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php80\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ion Bazan",
+                    "email": "ion.bazan@gmail.com"
+                },
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T16:19:22+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php81",
+            "version": "v1.38.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php81.git",
+                "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
+                "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php81\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-26T12:45:58+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php84",
+            "version": "v1.38.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php84.git",
+                "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
+                "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php84\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-26T12:51:13+00:00"
+        },
+        {
+            "name": "symfony/process",
+            "version": "v7.4.13",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/process.git",
+                "reference": "f5804be144caceb570f6747519999636b664f24c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c",
+                "reference": "f5804be144caceb570f6747519999636b664f24c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Process\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Executes commands in sub-processes",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/process/tree/v7.4.13"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-23T16:05:06+00:00"
+        },
+        {
+            "name": "symfony/service-contracts",
+            "version": "v3.7.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/service-contracts.git",
+                "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
+                "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/container": "^1.1|^2.0",
+                "symfony/deprecation-contracts": "^2.5|^3"
+            },
+            "conflict": {
+                "ext-psr": "<1.1|>=2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "3.7-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Service\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Test/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to writing services",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-06-16T09:55:08+00:00"
+        },
+        {
+            "name": "symfony/stopwatch",
+            "version": "v8.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/stopwatch.git",
+                "reference": "21c07b026905d596e8379caeb115d87aa479499d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d",
+                "reference": "21c07b026905d596e8379caeb115d87aa479499d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.4.1",
+                "symfony/service-contracts": "^2.5|^3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Stopwatch\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides a way to profile code",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/stopwatch/tree/v8.1.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-29T05:06:50+00:00"
+        },
+        {
+            "name": "symfony/string",
+            "version": "v8.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/string.git",
+                "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9",
+                "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.4.1",
+                "symfony/polyfill-ctype": "^1.8",
+                "symfony/polyfill-intl-grapheme": "^1.33",
+                "symfony/polyfill-intl-normalizer": "^1.0",
+                "symfony/polyfill-mbstring": "^1.0"
+            },
+            "conflict": {
+                "symfony/translation-contracts": "<2.5"
+            },
+            "require-dev": {
+                "symfony/emoji": "^7.4|^8.0",
+                "symfony/http-client": "^7.4|^8.0",
+                "symfony/intl": "^7.4|^8.0",
+                "symfony/translation-contracts": "^2.5|^3.0",
+                "symfony/var-exporter": "^7.4|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "Resources/functions.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Component\\String\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "grapheme",
+                "i18n",
+                "string",
+                "unicode",
+                "utf-8",
+                "utf8"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/string/tree/v8.1.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-05-29T05:06:50+00:00"
+        },
+        {
+            "name": "ta-tikoma/phpunit-architecture-test",
+            "version": "0.8.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git",
+                "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8",
+                "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.18.0 || ^5.0.0",
+                "php": "^8.1.0",
+                "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0",
+                "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0",
+                "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0"
+            },
+            "require-dev": {
+                "laravel/pint": "^1.13.7",
+                "phpstan/phpstan": "^1.10.52"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "PHPUnit\\Architecture\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ni Shi",
+                    "email": "futik0ma011@gmail.com"
+                },
+                {
+                    "name": "Nuno Maduro",
+                    "email": "enunomaduro@gmail.com"
+                }
+            ],
+            "description": "Methods for testing application architecture",
+            "keywords": [
+                "architecture",
+                "phpunit",
+                "stucture",
+                "test",
+                "testing"
+            ],
+            "support": {
+                "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues",
+                "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7"
+            },
+            "time": "2026-02-17T17:25:14+00:00"
+        },
+        {
+            "name": "theseer/tokenizer",
+            "version": "1.3.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theseer/tokenizer.git",
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+            "support": {
+                "issues": "https://github.com/theseer/tokenizer/issues",
+                "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-11-17T20:03:58+00:00"
+        },
+        {
+            "name": "webmozart/assert",
+            "version": "2.4.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/webmozarts/assert.git",
+                "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
+                "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
+                "shasum": ""
+            },
+            "require": {
+                "ext-ctype": "*",
+                "ext-date": "*",
+                "ext-filter": "*",
+                "php": "^8.2"
+            },
+            "suggest": {
+                "ext-intl": "",
+                "ext-simplexml": "",
+                "ext-spl": ""
+            },
+            "type": "library",
+            "extra": {
+                "psalm": {
+                    "pluginClass": "Webmozart\\Assert\\PsalmPlugin"
+                },
+                "branch-alias": {
+                    "dev-master": "2.0-dev",
+                    "dev-feature/2-0": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Webmozart\\Assert\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                },
+                {
+                    "name": "Woody Gilk",
+                    "email": "woody.gilk@gmail.com"
+                }
+            ],
+            "description": "Assertions to validate method input/output with nice error messages.",
+            "keywords": [
+                "assert",
+                "check",
+                "validate"
+            ],
+            "support": {
+                "issues": "https://github.com/webmozarts/assert/issues",
+                "source": "https://github.com/webmozarts/assert/tree/2.4.1"
+            },
+            "time": "2026-06-15T15:31:57+00:00"
+        }
+    ],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": [],
+    "prefer-stable": true,
+    "prefer-lowest": false,
+    "platform": {
+        "php": "^8.1",
+        "ext-json": "*",
+        "ext-mbstring": "*",
+        "ext-sockets": "*"
+    },
+    "platform-dev": [],
+    "plugin-api-version": "2.6.0"
+}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 0000000..8366ae2
--- /dev/null
+++ b/phpstan-baseline.neon
@@ -0,0 +1,2 @@
+parameters:
+	ignoreErrors: []
\ No newline at end of file
diff --git a/phpstan/stubs/cacti.stubs.php b/phpstan/stubs/cacti.stubs.php
new file mode 100644
index 0000000..8e38993
--- /dev/null
+++ b/phpstan/stubs/cacti.stubs.php
@@ -0,0 +1,381 @@
+ */
+global $config;
+
+/** @var string */
+global $database_default;
+
+/** @var string */
+global $action;
+
+/** @var array */
+global $utilities;
+
+/** @var array */
+global $menu;
+
+/** @var array */
+global $messages;
+
+/** @var array */
+global $audit_retentions;
+
+/** @var array */
+global $tabs;
+
+/** @var array */
+global $settings;
+
+/** @var array */
+global $item_rows;
+
+/* ----- Plugin / Hook API --------------------------------------------- */
+
+function api_plugin_register_hook(string $plugin, string $hook, string $function, string $file): void
+{
+}
+
+function api_plugin_register_realm(string $plugin, array $file, array $display, int $install = 0): void
+{
+}
+
+function api_plugin_is_enabled(string $plugin): bool
+{
+	return false;
+}
+
+function api_plugin_enable_hooks(string $plugin): void
+{
+}
+
+function api_plugin_replicate_config(): void
+{
+}
+
+function api_plugin_user_realm_auth(string $file = ''): bool
+{
+	return false;
+}
+
+function auth_augment_roles(string $label, array $files): void
+{
+}
+
+/* ----- Database ------------------------------------------------------- */
+
+function db_execute(string $sql, bool $log = true, mixed $db_conn = false): bool
+{
+	return false;
+}
+
+function db_execute_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false, bool $execute_prepared = true): bool
+{
+	return false;
+}
+
+/**
+ * @return array>|false
+ */
+function db_fetch_assoc(string $sql, bool $log = true, mixed $db_conn = false): array|false
+{
+	return false;
+}
+
+/**
+ * @return array>|false
+ */
+function db_fetch_assoc_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false): array|false
+{
+	return false;
+}
+
+/**
+ * @return array|false
+ */
+function db_fetch_row(string $sql, bool $log = true, mixed $db_conn = false): array|false
+{
+	return false;
+}
+
+/**
+ * @return array|false
+ */
+function db_fetch_row_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false): array|false
+{
+	return false;
+}
+
+function db_fetch_cell(string $sql, string $col_name = '', bool $log = true, mixed $db_conn = false): mixed
+{
+	return false;
+}
+
+function db_fetch_cell_prepared(string $sql, array $params = [], string $col_name = '', bool $log = true, mixed $db_conn = false): mixed
+{
+	return false;
+}
+
+function db_fetch_insert_id(string $table = ''): int
+{
+	return 0;
+}
+
+function db_affected_rows(mixed $db_conn = false): int
+{
+	return 0;
+}
+
+function db_column_exists(string $table, string $column, bool $log = true, mixed $db_conn = false): bool
+{
+	return false;
+}
+
+function db_table_exists(string $table, bool $log = true, mixed $db_conn = false): bool
+{
+	return false;
+}
+
+function db_index_exists(string $table, string $index, bool $log = true, mixed $db_conn = false): bool
+{
+	return false;
+}
+
+function db_add_index(string $table, string $type, string $name, string $definition, bool $log = true, mixed $db_conn = false): void
+{
+}
+
+/* ----- Config / Options --------------------------------------------- */
+
+function read_config_option(string $name, bool $global = false): mixed
+{
+	return false;
+}
+
+function set_config_option(string $name, string $value): bool
+{
+	return false;
+}
+
+/* ----- Localization -------------------------------------------------- */
+
+function __(string $text, string $domain = ''): string
+{
+	return $text;
+}
+
+/**
+ * @param mixed ...$args
+ */
+function __esc(string $text, string $domain = ''): string
+{
+	return $text;
+}
+
+/* ----- Input Handling ------------------------------------------------ */
+
+function get_request_var(string $name, mixed $default = '', string $filter = ''): mixed
+{
+	return $default;
+}
+
+function get_filter_request_var(string $name, int $filter = FILTER_DEFAULT, array $options = []): mixed
+{
+	return false;
+}
+
+function get_nfilter_request_var(string $name, mixed $default = '', string $filter = ''): mixed
+{
+	return $default;
+}
+
+function isset_request_var(string $name): bool
+{
+	return false;
+}
+
+function isempty_request_var(string $name): bool
+{
+	return false;
+}
+
+function set_default_action(string $default = ''): void
+{
+}
+
+/**
+ * @param array $filters
+ */
+function validate_store_request_vars(array $filters, string $sess_prefix = ''): void
+{
+}
+
+function sanitize_search_string(mixed $string): string
+{
+	return '';
+}
+
+function html_escape_request_var(string $name): void
+{
+}
+
+/* ----- Logging / Misc ------------------------------------------------ */
+
+function cacti_log(string $string, bool $output = false, string $environment = '', int $level = 1, bool $force = false): bool
+{
+	return false;
+}
+
+function cacti_sizeof(mixed $array): int
+{
+	return is_array($array) ? count($array) : 0;
+}
+
+function get_client_addr(): string
+{
+	return '';
+}
+
+function get_username(int $user_id): string
+{
+	return '';
+}
+
+function raise_message(string $id, string $message = '', int $level = 0): void
+{
+}
+
+function csrf_check(bool $force = false): bool
+{
+	return false;
+}
+
+/* ----- UI / HTML Helpers --------------------------------------------- */
+
+function top_header(): void
+{
+}
+
+function bottom_footer(): void
+{
+}
+
+function html_start_box(string $title, string $width, string $align = '', string $add = '', string $collapsible = '', string $url = ''): void
+{
+}
+
+function html_end_box(bool $trailing_br = true): void
+{
+}
+
+/**
+ * @param array ...$args
+ */
+function html_nav_bar(string $base_url, int $max_pages, int $current_page, int $rows_per_page, int $total_rows, int $pages_to_display, string $title, string $page_var = 'page', string $class = 'main'): string
+{
+	return '';
+}
+
+/**
+ * @param array $display_text
+ */
+function html_header_sort(array $display_text, string $sort_column, string $sort_direction, bool $last_column = false): void
+{
+}
+
+function html_escape(string $string): string
+{
+	return '';
+}
+
+function form_alternate_row(string $id, bool $light = false): void
+{
+}
+
+function form_selectable_cell(string $text, int $id, string $class = '', string $title = ''): void
+{
+}
+
+function form_selectable_ecell(string $text, int $id): void
+{
+}
+
+function form_end_row(): void
+{
+}
+
+function filter_value(string $text, string $filter): string
+{
+	return '';
+}
+
+function get_order_string(): string
+{
+	return '';
+}
+
+/**
+ * @param array>|false $array
+ */
+function array_rekey(array|false $array, string $key, string $key_value): array
+{
+	return [];
+}
\ No newline at end of file

From b7b3d42293bfc30ea483fce2fa494a02d5dd5888 Mon Sep 17 00:00:00 2001
From: Sean Mancini 
Date: Sat, 25 Jul 2026 16:04:11 -0400
Subject: [PATCH 23/36] Apply PHP-CS-Fixer auto-formatting (Cacti ruleset)

- Convert array() to short array syntax []
- Normalize switch/case indentation (PSR-12)
- Convert dirname(__FILE__) to __DIR__
- Normalize else if to elseif
- Apply binary operator alignment (=> = ===)
- Single quotes, concat spaces, trailing whitespace
- Fix controller_security_test assertions for short array syntax
- Fix SetupStructureTest to check INFO file instead of source regex

Phase 2 of code quality sweep. No functionality changes.
---
 audit.php                                     | 630 +++++++++---------
 audit_functions.php                           | 336 +++++-----
 audit_syslog.php                              | 505 +++++++-------
 index.php                                     |   2 +-
 phpstan/stubs/cacti.stubs.php                 | 184 ++---
 setup.php                                     | 471 ++++++-------
 tests/Pest.php                                |   4 +-
 tests/Security/Php74CompatibilityTest.php     |   4 +-
 .../PreparedStatementConsistencyTest.php      |   5 +-
 tests/Security/SetupStructureTest.php         |  20 +-
 tests/bootstrap.php                           |  24 +-
 tests/controller_security_test.php            |  22 +-
 tests/security_functions_test.php             | 147 ++--
 tests/syslog_functions_test.php               | 243 +++----
 tests/syslog_queue_test.php                   | 100 +--
 15 files changed, 1374 insertions(+), 1323 deletions(-)

diff --git a/audit.php b/audit.php
index 8703ec0..02f30f4 100644
--- a/audit.php
+++ b/audit.php
@@ -28,127 +28,128 @@
 set_default_action();
 
 switch(get_request_var('action')) {
-case 'export':
-	audit_export_rows();
-
-	break;
-case 'syslog_test':
-	if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
-		http_response_code(405);
-		header('Allow: POST');
-		exit;
-	}
+	case 'export':
+		audit_export_rows();
 
-	if (!audit_user_is_admin() || !csrf_check(false)) {
-		http_response_code(403);
-		exit;
-	}
+		break;
+	case 'syslog_test':
+		if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+			http_response_code(405);
+			header('Allow: POST');
+			exit;
+		}
 
-	$result = audit_syslog_test_delivery();
-	$success = $result['status'] === 'sent_unconfirmed';
-	audit_record_event('audit.syslog.test', array(
-		'event_category' => 'audit',
-		'severity' => $success ? 'notice' : 'warning',
-		'action' => 'test',
-		'target_type' => 'syslog_receiver',
-		'operation_outcome' => $success ? 'success' : 'failure',
-		'outcome_reason' => $success ? 'socket_write_completed' : ($result['error_code'] ?? 'delivery_failed'),
-		'details' => array(
-			'transport' => audit_syslog_config()['transport'],
-			'status' => $result['status'],
-			'error_code' => $result['error_code'] ?? ''
-		)
-	));
-	$_SESSION['audit_message'] = $success
-		? __('Syslog test record was written to the local socket. Receiver storage is unconfirmed.', 'audit')
-		: __('Syslog test failed: %s', html_escape($result['error'] ?? 'Unknown error'), 'audit');
-	raise_message('audit_message');
+		if (!audit_user_is_admin() || !csrf_check(false)) {
+			http_response_code(403);
+			exit;
+		}
 
-	top_header();
-	audit_log();
-	bottom_footer();
+		$result  = audit_syslog_test_delivery();
+		$success = $result['status'] === 'sent_unconfirmed';
+		audit_record_event('audit.syslog.test', [
+			'event_category'    => 'audit',
+			'severity'          => $success ? 'notice' : 'warning',
+			'action'            => 'test',
+			'target_type'       => 'syslog_receiver',
+			'operation_outcome' => $success ? 'success' : 'failure',
+			'outcome_reason'    => $success ? 'socket_write_completed' : ($result['error_code'] ?? 'delivery_failed'),
+			'details'           => [
+				'transport'  => audit_syslog_config()['transport'],
+				'status'     => $result['status'],
+				'error_code' => $result['error_code'] ?? ''
+			]
+		]);
+		$_SESSION['audit_message'] = $success
+			? __('Syslog test record was written to the local socket. Receiver storage is unconfirmed.', 'audit')
+			: __('Syslog test failed: %s', html_escape($result['error'] ?? 'Unknown error'), 'audit');
+		raise_message('audit_message');
+
+		top_header();
+		audit_log();
+		bottom_footer();
 
-	break;
-case 'syslog_retry':
-	if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
-		http_response_code(405);
-		header('Allow: POST');
-		exit;
-	}
-
-	if (!audit_user_is_admin() || !csrf_check(false)) {
-		http_response_code(403);
-		exit;
-	}
+		break;
+	case 'syslog_retry':
+		if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+			http_response_code(405);
+			header('Allow: POST');
+			exit;
+		}
 
-	$post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW);
-	$post = is_array($post) ? $post : array();
-	$delivery_ids = isset($post['delivery_ids']) && is_array($post['delivery_ids'])
-		? $post['delivery_ids']
-		: array();
-	$retried = audit_syslog_retry_dead_letters($delivery_ids);
-	audit_record_event('audit.syslog.dead_letter.retried', array(
-		'event_category' => 'audit',
-		'severity' => 'notice',
-		'action' => 'retry',
-		'target_type' => 'syslog_delivery',
-		'operation_outcome' => 'success',
-		'details' => array('row_count' => $retried)
-	));
-	$_SESSION['audit_message'] = __('Reset %d dead-letter Syslog deliveries for retry.', $retried, 'audit');
-	raise_message('audit_message');
+		if (!audit_user_is_admin() || !csrf_check(false)) {
+			http_response_code(403);
+			exit;
+		}
 
-	top_header();
-	audit_log();
-	bottom_footer();
+		$post         = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW);
+		$post         = is_array($post) ? $post : [];
+		$delivery_ids = isset($post['delivery_ids']) && is_array($post['delivery_ids'])
+			? $post['delivery_ids']
+			: [];
+		$retried = audit_syslog_retry_dead_letters($delivery_ids);
+		audit_record_event('audit.syslog.dead_letter.retried', [
+			'event_category'    => 'audit',
+			'severity'          => 'notice',
+			'action'            => 'retry',
+			'target_type'       => 'syslog_delivery',
+			'operation_outcome' => 'success',
+			'details'           => ['row_count' => $retried]
+		]);
+		$_SESSION['audit_message'] = __('Reset %d dead-letter Syslog deliveries for retry.', $retried, 'audit');
+		raise_message('audit_message');
+
+		top_header();
+		audit_log();
+		bottom_footer();
 
-	break;
-case 'purge':
-	if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
-		http_response_code(405);
-		header('Allow: POST');
-		exit;
-	}
+		break;
+	case 'purge':
+		if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+			http_response_code(405);
+			header('Allow: POST');
+			exit;
+		}
 
-	if (!audit_user_is_admin() || !csrf_check(false)) {
-		http_response_code(403);
-		exit;
-	}
+		if (!audit_user_is_admin() || !csrf_check(false)) {
+			http_response_code(403);
+			exit;
+		}
 
-	audit_purge();
+		audit_purge();
 
-	top_header();
-	audit_log();
-	bottom_footer();
+		top_header();
+		audit_log();
+		bottom_footer();
 
-	break;
-case 'getdata':
-	$data = db_fetch_row_prepared('SELECT *
+		break;
+	case 'getdata':
+		$data = db_fetch_row_prepared('SELECT *
 			FROM audit_log
 			WHERE id = ?',
-		array(get_filter_request_var('id')));
+			[get_filter_request_var('id')]);
 
-	if (!cacti_sizeof($data)) {
-		http_response_code(404);
-		print html_escape(__('Audit event not found.', 'audit'));
-		break;
-	}
+		if (!cacti_sizeof($data)) {
+			http_response_code(404);
+			print html_escape(__('Audit event not found.', 'audit'));
 
-	audit_record_event('audit.event.viewed', array(
-		'event_category' => 'audit',
-		'target_type' => 'audit_event',
-		'target_id' => $data['event_uuid'] != '' ? $data['event_uuid'] : $data['id'],
-		'details' => array('record_id' => $data['id'])
-	));
-
-	$output = audit_render_event_details($data);
-	echo $output;
-
-	break;
-default:
-	top_header();
-	audit_log();
-	bottom_footer();
+			break;
+		}
+
+		audit_record_event('audit.event.viewed', [
+			'event_category' => 'audit',
+			'target_type'    => 'audit_event',
+			'target_id'      => $data['event_uuid'] != '' ? $data['event_uuid'] : $data['id'],
+			'details'        => ['record_id' => $data['id']]
+		]);
+
+		$output = audit_render_event_details($data);
+		print $output;
+
+		break;
+	default:
+		top_header();
+		audit_log();
+		bottom_footer();
 }
 
 function audit_render_event_details($data) {
@@ -163,10 +164,12 @@ function audit_render_event_details($data) {
 	$output .= '
' . __('Event ID:', 'audit') . ' ' . html_escape($data['event_uuid']) . ''; $output .= '
' . __('Request Status:', 'audit') . ' ' . html_escape($data['request_status']) . ''; $output .= '
' . __('Operation Outcome:', 'audit') . ' ' . html_escape($data['operation_outcome']) . ''; + if ($data['outcome_reason'] != '') { $output .= '
' . __('Outcome Reason:', 'audit') . ' ' . html_escape($data['outcome_reason']) . ''; } $output .= '
' . __('External File Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . ''; + if ($data['external_error'] != '') { $output .= '
' . __('External File Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; } @@ -177,14 +180,16 @@ function audit_render_event_details($data) { WHERE audit_id = ? ORDER BY id DESC LIMIT 1', - array($data['id'])); + [$data['id']]); if (cacti_sizeof($syslog)) { $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($syslog['state']) . ''; $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . (int) $syslog['attempts'] . ''; + if ($syslog['sent_time'] != '') { $output .= '
' . __('Syslog Socket Write:', 'audit') . ' ' . html_escape($syslog['sent_time']) . ''; } + if ($syslog['last_error'] != '') { $output .= '
' . __('Syslog Error:', 'audit') . ' ' . html_escape($syslog['last_error']) . ''; } @@ -194,13 +199,14 @@ function audit_render_event_details($data) { if ($data['action'] == 'cli') { $output .= '' . __('Script:', 'audit') . ' ' . html_escape($data['post']) . ''; + return $output . ''; } $attribs = audit_json_decode($data['post'], $json_error); - $attribs = is_array($attribs) ? $attribs : array( + $attribs = is_array($attribs) ? $attribs : [ __('Stored Data', 'audit') => __('The stored request data is not valid JSON: %s', $json_error, 'audit') - ); + ]; ksort($attribs); $columns = cacti_sizeof($attribs) > 16 ? 2 : 1; @@ -210,6 +216,7 @@ function audit_render_event_details($data) { : '' . __('Attrib', 'audit') . '' . __('Value', 'audit') . ''; $i = 0; + foreach ($attribs as $field => $content) { if ($i % $columns == 0) { $output .= ''; @@ -228,6 +235,7 @@ function audit_render_event_details($data) { } $record_data = audit_json_decode($data['object_data'], $object_error); + if (is_array($record_data) && !empty($record_data)) { $output .= '
'; $output .= '' . __('Record Data:', 'audit') . ''; @@ -273,16 +281,16 @@ function audit_purge() { )"); $purged = db_affected_rows(); - audit_record_event('audit.log.purged', array( + audit_record_event('audit.log.purged', [ 'event_category' => 'audit', - 'severity' => 'warning', - 'action' => 'purge', - 'target_type' => 'audit_log', - 'details' => array( - 'purged_count' => $purged, + 'severity' => 'warning', + 'action' => 'purge', + 'target_type' => 'audit_log', + 'details' => [ + 'purged_count' => $purged, 'protected_count' => (int) $protected - ) - )); + ] + ]); $_SESSION['audit_message'] = __('Audit Log purge by %s removed %d events and protected %d events with unfinished Syslog delivery.', get_username($_SESSION['sess_user_id']), $purged, (int) $protected, 'audit'); @@ -295,9 +303,9 @@ function audit_purge() { function audit_export_rows() { audit_process_request_vars(); - /* form the 'where' clause for our main sql query */ - $sql_clauses = array(); - $sql_params = array(); + // form the 'where' clause for our main sql query + $sql_clauses = []; + $sql_params = []; if (get_request_var('filter') != '') { $sql_clauses[] = '(page LIKE ? OR post LIKE ?)'; @@ -324,17 +332,17 @@ function audit_export_rows() { $sql_where", $sql_params); - audit_record_event('audit.log.exported', array( + audit_record_event('audit.log.exported', [ 'event_category' => 'audit', - 'action' => 'export', - 'target_type' => 'audit_log', - 'details' => array( - 'row_count' => cacti_sizeof($events), - 'filter' => get_request_var('filter'), + 'action' => 'export', + 'target_type' => 'audit_log', + 'details' => [ + 'row_count' => cacti_sizeof($events), + 'filter' => get_request_var('filter'), 'event_page' => get_request_var('event_page'), - 'user_id' => get_request_var('user_id') - ) - )); + 'user_id' => get_request_var('user_id') + ] + ]); if (cacti_sizeof($events)) { header('Content-Disposition: attachment; filename=audit_export.csv'); @@ -342,9 +350,9 @@ function audit_export_rows() { header('X-Content-Type-Options: nosniff'); $output = fopen('php://output', 'w'); - fputcsv($output, array('event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'page', 'user_id', 'username', 'action', 'request_status', 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', 'external_status', 'external_error', 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', 'completed_time', 'duration_ms', 'integrity_hash', 'post', 'details'), ',', '"', ''); + fputcsv($output, ['event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'page', 'user_id', 'username', 'action', 'request_status', 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', 'external_status', 'external_error', 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', 'completed_time', 'duration_ms', 'integrity_hash', 'post', 'details'], ',', '"', ''); - foreach($events as $event) { + foreach ($events as $event) { if ($event['action'] == 'cli') { $poster = $event['post']; } else { @@ -352,34 +360,34 @@ function audit_export_rows() { $poster = is_array($post) ? json_encode($post, JSON_INVALID_UTF8_SUBSTITUTE) : $event['post']; } - fputcsv($output, array_map('audit_csv_safe_cell', array( - $event['event_uuid'], - $event['correlation_id'], - $event['event_type'], - $event['event_category'], - $event['severity'], - $event['page'], - $event['user_id'], - get_username($event['user_id']), - $event['action'], - $event['request_status'], - $event['operation_outcome'], - $event['outcome_reason'], - $event['target_type'], - $event['target_id'], - $event['external_status'], - $event['external_error'], - $event['ip_address'], - $event['user_agent'], - $event['http_method'], - $event['http_status'], - $event['event_time'], - $event['completed_time'], - $event['duration_ms'], - $event['integrity_hash'], - $poster, - $event['details'] - )), ',', '"', ''); + fputcsv($output, array_map('audit_csv_safe_cell', [ + $event['event_uuid'], + $event['correlation_id'], + $event['event_type'], + $event['event_category'], + $event['severity'], + $event['page'], + $event['user_id'], + get_username($event['user_id']), + $event['action'], + $event['request_status'], + $event['operation_outcome'], + $event['outcome_reason'], + $event['target_type'], + $event['target_id'], + $event['external_status'], + $event['external_error'], + $event['ip_address'], + $event['user_agent'], + $event['http_method'], + $event['http_status'], + $event['event_time'], + $event['completed_time'], + $event['duration_ms'], + $event['integrity_hash'], + $poster, + $event['details'] + ]), ',', '"', ''); } fclose($output); @@ -387,47 +395,47 @@ function audit_export_rows() { } function audit_process_request_vars() { - /* ================= input validation and session storage ================= */ - $filters = array( - 'rows' => array( - 'filter' => FILTER_VALIDATE_INT, + // ================= input validation and session storage ================= + $filters = [ + 'rows' => [ + 'filter' => FILTER_VALIDATE_INT, 'pageset' => true, 'default' => '-1' - ), - 'page' => array( - 'filter' => FILTER_VALIDATE_INT, + ], + 'page' => [ + 'filter' => FILTER_VALIDATE_INT, 'default' => '1' - ), - 'filter' => array( - 'filter' => FILTER_DEFAULT, + ], + 'filter' => [ + 'filter' => FILTER_DEFAULT, 'pageset' => true, 'default' => '' - ), - 'sort_column' => array( - 'filter' => FILTER_CALLBACK, + ], + 'sort_column' => [ + 'filter' => FILTER_CALLBACK, 'default' => 'event_time', - 'options' => array('options' => 'sanitize_search_string') - ), - 'sort_direction' => array( - 'filter' => FILTER_CALLBACK, + 'options' => ['options' => 'sanitize_search_string'] + ], + 'sort_direction' => [ + 'filter' => FILTER_CALLBACK, 'default' => 'DESC', - 'options' => array('options' => 'sanitize_search_string') - ), - 'user_id' => array( - 'filter' => FILTER_VALIDATE_INT, + 'options' => ['options' => 'sanitize_search_string'] + ], + 'user_id' => [ + 'filter' => FILTER_VALIDATE_INT, 'pageset' => true, 'default' => '-1' - ), - 'event_page' => array( - 'filter' => FILTER_CALLBACK, + ], + 'event_page' => [ + 'filter' => FILTER_CALLBACK, 'pageset' => true, 'default' => '-1', - 'options' => array('options' => 'sanitize_search_string') - ) - ); + 'options' => ['options' => 'sanitize_search_string'] + ] + ]; validate_store_request_vars($filters, 'sess_audit'); - /* ================= input validation ================= */ + // ================= input validation ================= } function audit_log() { @@ -435,18 +443,18 @@ function audit_log() { audit_process_request_vars(); $has_filters = get_request_var('filter') != '' || - get_request_var('event_page') != '-1' || + get_request_var('event_page') != '-1' || (!isempty_request_var('user_id') && get_request_var('user_id') > '-1'); - audit_record_event($has_filters ? 'audit.log.searched' : 'audit.log.viewed', array( + audit_record_event($has_filters ? 'audit.log.searched' : 'audit.log.viewed', [ 'event_category' => 'audit', - 'action' => $has_filters ? 'search' : 'view', - 'target_type' => 'audit_log', - 'details' => array( - 'filter' => get_request_var('filter'), + 'action' => $has_filters ? 'search' : 'view', + 'target_type' => 'audit_log', + 'details' => [ + 'filter' => get_request_var('filter'), 'event_page' => get_request_var('event_page'), - 'user_id' => get_request_var('user_id') - ) - )); + 'user_id' => get_request_var('user_id') + ] + ]); if (get_request_var('rows') == '-1') { $rows = read_config_option('num_rows_table'); @@ -467,73 +475,89 @@ function audit_log() {
- + - '> + '> - + - + - + - - - + + + - +
- '> + '> @@ -541,9 +565,9 @@ function audit_log() { html_end_box(); - /* form the 'where' clause for our main sql query */ - $sql_clauses = array(); - $sql_params = array(); + // form the 'where' clause for our main sql query + $sql_clauses = []; + $sql_params = []; if (get_request_var('filter') != '') { $sql_clauses[] = '(page LIKE ? OR post LIKE ?)'; @@ -572,7 +596,7 @@ function audit_log() { $sql_params); $sql_order = get_order_string(); - $sql_limit = ' LIMIT ' . ($rows*(get_request_var('page')-1)) . ',' . $rows; + $sql_limit = ' LIMIT ' . ($rows * (get_request_var('page') - 1)) . ',' . $rows; $events = db_fetch_assoc_prepared("SELECT audit_log.*, user_auth.username FROM audit_log @@ -589,88 +613,89 @@ function audit_log() { html_start_box('', '100%', '', '3', 'center', ''); - $display_text = array( - 'page' => array( + $display_text = [ + 'page' => [ 'display' => __('Page Name', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('The page where the event was generated.', 'audit') - ), - 'username' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('The page where the event was generated.', 'audit') + ], + 'username' => [ 'display' => __('User Name', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('The user who generated the event.', 'audit') - ), - 'action' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('The user who generated the event.', 'audit') + ], + 'action' => [ 'display' => __('Requested Action', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('The requested Cacti action. Hover over the action to see request data.', 'audit') - ), - 'request_status' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('The requested Cacti action. Hover over the action to see request data.', 'audit') + ], + 'request_status' => [ 'display' => __('Request Status', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('Request processing state; completion does not guarantee that every operation succeeded.', 'audit') - ), - 'external_status' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('Request processing state; completion does not guarantee that every operation succeeded.', 'audit') + ], + 'external_status' => [ 'display' => __('External File Delivery', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('Delivery state for the optional external audit log file. Remote Syslog health is shown separately.', 'audit') - ), - 'user_agent' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('Delivery state for the optional external audit log file. Remote Syslog health is shown separately.', 'audit') + ], + 'user_agent' => [ 'display' => __('User Agent', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('The browser type of the requester.', 'audit') - ), - 'ip_address' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('The browser type of the requester.', 'audit') + ], + 'ip_address' => [ 'display' => __('IP Address', 'audit'), - 'align' => 'right', - 'sort' => 'ASC', - 'tip' => __('The IP Address of the requester.', 'audit') - ), - 'event_time' => array( + 'align' => 'right', + 'sort' => 'ASC', + 'tip' => __('The IP Address of the requester.', 'audit') + ], + 'event_time' => [ 'display' => __('Event Time', 'audit'), - 'align' => 'right', - 'sort' => 'DESC', - 'tip' => __('The time the Event took place.', 'audit') - ) - ); + 'align' => 'right', + 'sort' => 'DESC', + 'tip' => __('The time the Event took place.', 'audit') + ] + ]; html_header_sort($display_text, get_request_var('sort_column'), get_request_var('sort_direction'), false); $i = 0; + if (cacti_sizeof($events)) { - foreach ($events as $e) { - if ($e['action'] == 'cli') { - form_alternate_row('line' . $e['id'], false); - form_selectable_ecell($e['page'], $e['id']); - form_selectable_ecell($e['user_agent'], $e['id']); - form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); - form_selectable_ecell($e['request_status'], $e['id']); - form_selectable_ecell($e['external_status'], $e['id']); - form_selectable_cell(__('N/A', 'audit'), $e['id']); - form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); - form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); - form_end_row(); - } else { - form_alternate_row('line' . $e['id'], false); - form_selectable_cell(filter_value($e['page'], get_request_var('filter')), $e['id']); - form_selectable_ecell($e['username'], $e['id']); - form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); - form_selectable_ecell($e['request_status'], $e['id']); - form_selectable_ecell($e['external_status'], $e['id']); - form_selectable_ecell($e['user_agent'], $e['id']); - form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); - form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); + foreach ($events as $e) { + if ($e['action'] == 'cli') { + form_alternate_row('line' . $e['id'], false); + form_selectable_ecell($e['page'], $e['id']); + form_selectable_ecell($e['user_agent'], $e['id']); + form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); + form_selectable_ecell($e['request_status'], $e['id']); + form_selectable_ecell($e['external_status'], $e['id']); + form_selectable_cell(__('N/A', 'audit'), $e['id']); + form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); + form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); + form_end_row(); + } else { + form_alternate_row('line' . $e['id'], false); + form_selectable_cell(filter_value($e['page'], get_request_var('filter')), $e['id']); + form_selectable_ecell($e['username'], $e['id']); + form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); + form_selectable_ecell($e['request_status'], $e['id']); + form_selectable_ecell($e['external_status'], $e['id']); + form_selectable_ecell($e['user_agent'], $e['id']); + form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); + form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); form_end_row(); } } } else { - print "" . __('No Audit Log Events Found', 'audit') . "\n"; + print "" . __('No Audit Log Events Found', 'audit') . "\n"; } html_end_box(false); @@ -685,9 +710,9 @@ function audit_log() { } function audit_render_syslog_health() { - $config = audit_syslog_config(); - $health = audit_syslog_health(); - $enabled = audit_syslog_enabled(); + $config = audit_syslog_config(); + $health = audit_syslog_health(); + $enabled = audit_syslog_enabled(); $unhealthy = $enabled && ( !$config['valid'] || $health['dead_letter'] >= $config['dead_letter_warning'] || @@ -711,6 +736,7 @@ function audit_render_syslog_health() { print '' . __('Last Socket Write', 'audit') . '' . html_escape($health['last_sent'] ?? __('Never', 'audit')) . ''; print ''; print " '; + if ($health['dead_letter'] > 0) { print "'; diff --git a/audit_functions.php b/audit_functions.php index 90e7eef..9dc044b 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -1,125 +1,129 @@ $value) { if (audit_is_sensitive_key($key)) { @@ -167,7 +171,7 @@ function audit_redact_sensitive_value($value) { function audit_bound_log_data($data, $depth = 0, $state = null) { if ($state === null) { - $state = (object) array('fields' => 0); + $state = (object) ['fields' => 0]; } if ($depth >= 12) { @@ -175,11 +179,12 @@ function audit_bound_log_data($data, $depth = 0, $state = null) { } if (is_array($data)) { - $bounded = array(); + $bounded = []; foreach ($data as $key => $value) { if ($state->fields >= 1000) { $bounded['audit_truncated'] = 'Additional fields were omitted.'; + break; } @@ -198,24 +203,27 @@ function audit_bound_log_data($data, $depth = 0, $state = null) { } function audit_redact_cli_arguments($arguments) { - $redacted = array(); + $redacted = []; $redact_next = false; foreach ($arguments as $argument) { if ($redact_next) { - $redacted[] = '[REDACTED]'; + $redacted[] = '[REDACTED]'; $redact_next = false; + continue; } if (preg_match('/^(--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)[^=]*)=(.*)$/i', $argument, $matches)) { $redacted[] = $matches[1] . '=[REDACTED]'; + continue; } if (preg_match('/^--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)/i', $argument)) { - $redacted[] = $argument; + $redacted[] = $argument; $redact_next = true; + continue; } @@ -229,7 +237,7 @@ function audit_json_encode($data, $options = 0) { $json = json_encode(audit_bound_log_data($data), JSON_INVALID_UTF8_SUBSTITUTE | $options, 16); if ($json === false) { - return json_encode(array('audit_encoding_error' => json_last_error_msg())); + return json_encode(['audit_encoding_error' => json_last_error_msg()]); } return $json; @@ -242,15 +250,16 @@ function audit_json_decode($json, &$error = null) { return json_decode($json, true, 16, JSON_THROW_ON_ERROR); } catch (Throwable $exception) { $error = $exception->getMessage(); + return null; } } function audit_uuid_v4() { - $bytes = random_bytes(16); + $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); - $hex = bin2hex($bytes); + $hex = bin2hex($bytes); return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20); @@ -280,7 +289,7 @@ function audit_utc_time($microtime = null) { } function audit_event_integrity_hash($event) { - $material = array( + $material = [ 'event_uuid' => $event['event_uuid'] ?? '', 'correlation_id' => $event['correlation_id'] ?? '', 'event_type' => $event['event_type'] ?? '', @@ -291,7 +300,7 @@ function audit_event_integrity_hash($event) { 'target_type' => $event['target_type'] ?? '', 'target_id' => $event['target_id'] ?? '', 'details' => $event['details'] ?? '' - ); + ]; return hash('sha256', audit_json_encode($material, JSON_UNESCAPED_SLASHES)); } @@ -307,15 +316,15 @@ function audit_event_type_for_request($page, $action) { } function audit_external_event_data($event) { - $fields = array( + $fields = [ 'id', 'event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'actor_type', 'page', 'user_id', 'action', 'request_status', 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', 'completed_time', 'duration_ms', 'post', 'object_data', 'details', 'previous_hash', 'integrity_hash' - ); - $data = array(); + ]; + $data = []; foreach ($fields as $field) { $data[$field] = $event[$field] ?? null; @@ -326,7 +335,7 @@ function audit_external_event_data($event) { function audit_external_log_format($data, $format = 'json') { if ($format === 'text') { - $fields = array(); + $fields = []; foreach ($data as $name => $value) { if (is_array($value) || is_object($value)) { @@ -338,8 +347,8 @@ function audit_external_log_format($data, $format = 'json') { } $value = str_replace( - array('\\', "\r", "\n", "\t", '"'), - array('\\\\', '\r', '\n', '\t', '\"'), + ['\\', "\r", "\n", "\t", '"'], + ['\\\\', '\r', '\n', '\t', '\"'], (string) $value ); $fields[] = $name . '="' . $value . '"'; @@ -348,7 +357,7 @@ function audit_external_log_format($data, $format = 'json') { return implode(' ', $fields) . "\n"; } - foreach (array('post', 'object_data', 'details') as $name) { + foreach (['post', 'object_data', 'details'] as $name) { if (isset($data[$name]) && is_string($data[$name])) { $decoded = audit_json_decode($data[$name], $error); @@ -381,15 +390,16 @@ function audit_retention_cutoff($retention, $now = null) { function audit_append_external_log($path, $message) { if ($path == '' || !is_file($path) || is_link($path)) { - return array('status' => 'failed', 'error' => 'Destination is not a regular file or is a symbolic link.'); + return ['status' => 'failed', 'error' => 'Destination is not a regular file or is a symbolic link.']; } $written = file_put_contents($path, $message, FILE_APPEND | LOCK_EX); + if ($written !== strlen($message)) { - return array('status' => 'failed', 'error' => 'Unable to append a complete record.'); + return ['status' => 'failed', 'error' => 'Unable to append a complete record.']; } - return array('status' => 'delivered', 'error' => ''); + return ['status' => 'delivered', 'error' => '']; } function audit_set_external_status($id, $status, $error = '') { @@ -400,7 +410,7 @@ function audit_set_external_status($id, $status, $error = '') { external_last_attempt = UTC_TIMESTAMP(6), external_delivered_time = CASE WHEN ? = "delivered" THEN UTC_TIMESTAMP(6) ELSE external_delivered_time END WHERE id = ?', - array($status, $error, $status, $id)); + [$status, $error, $status, $id]); } function audit_deliver_external_event($id) { @@ -408,14 +418,17 @@ function audit_deliver_external_event($id) { return; } - $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id)); + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); + if (!cacti_sizeof($event) || $event['request_status'] == 'started') { return; } $path = read_config_option('audit_log_external_path'); + if ($path == '' || !is_file($path) || is_link($path)) { audit_set_external_status($id, 'failed', 'Destination is not a regular file or is a symbolic link.'); + return; } @@ -431,6 +444,7 @@ function audit_retry_external_logs() { } $path = read_config_option('audit_log_external_path'); + if ($path == '' || !is_file($path) || is_link($path)) { return; } @@ -457,7 +471,7 @@ function audit_retry_external_logs() { } function audit_request_status($error = null, $status_code = 200) { - $fatal_types = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR); + $fatal_types = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; if ((is_array($error) && in_array($error['type'] ?? null, $fatal_types, true)) || $status_code >= 400) { @@ -473,15 +487,17 @@ function audit_operation_verifier_for_request($page, $post) { } $target_user_id = $post['id'] ?? null; + if (!is_scalar($target_user_id) || !preg_match('/^[1-9][0-9]*$/', (string) $target_user_id)) { - return array( - 'type' => 'invalid', + return [ + 'type' => 'invalid', 'outcome_reason' => 'realm_permissions_request_invalid' - ); + ]; } - $expected_realm_ids = array(); + $expected_realm_ids = []; + foreach ($post as $field => $value) { $field = (string) $field; @@ -490,10 +506,10 @@ function audit_operation_verifier_for_request($page, $post) { } if (!preg_match('/^section([1-9][0-9]*)$/', $field, $matches)) { - return array( - 'type' => 'invalid', + return [ + 'type' => 'invalid', 'outcome_reason' => 'realm_permissions_request_invalid' - ); + ]; } $expected_realm_ids[] = (int) $matches[1]; @@ -502,55 +518,56 @@ function audit_operation_verifier_for_request($page, $post) { $expected_realm_ids = array_values(array_unique($expected_realm_ids)); sort($expected_realm_ids, SORT_NUMERIC); - return array( - 'type' => 'user_realm_permissions', - 'target_user_id' => (int) $target_user_id, + return [ + 'type' => 'user_realm_permissions', + 'target_user_id' => (int) $target_user_id, 'expected_realm_ids' => $expected_realm_ids - ); + ]; } function audit_verify_operation($verifier) { if (!is_array($verifier) || empty($verifier['type'])) { - return array('outcome' => 'unknown', 'reason' => null); + return ['outcome' => 'unknown', 'reason' => null]; } if ($verifier['type'] == 'invalid') { - return array( + return [ 'outcome' => 'unknown', - 'reason' => $verifier['outcome_reason'] ?? 'verification_request_invalid' - ); + 'reason' => $verifier['outcome_reason'] ?? 'verification_request_invalid' + ]; } if ($verifier['type'] != 'user_realm_permissions') { - return array('outcome' => 'unknown', 'reason' => 'verification_type_unsupported'); + return ['outcome' => 'unknown', 'reason' => 'verification_type_unsupported']; } - $target_user_id = (int) ($verifier['target_user_id'] ?? 0); - $expected_realm_ids = $verifier['expected_realm_ids'] ?? array(); - $user_count = db_fetch_cell_prepared('SELECT COUNT(*) FROM user_auth WHERE id = ?', array($target_user_id)); + $target_user_id = (int) ($verifier['target_user_id'] ?? 0); + $expected_realm_ids = $verifier['expected_realm_ids'] ?? []; + $user_count = db_fetch_cell_prepared('SELECT COUNT(*) FROM user_auth WHERE id = ?', [$target_user_id]); if ($user_count === false) { - return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'); + return ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed']; } if ((int) $user_count !== 1) { - return array('outcome' => 'failure', 'reason' => 'target_user_not_found'); + return ['outcome' => 'failure', 'reason' => 'target_user_not_found']; } $rows = db_fetch_assoc_prepared('SELECT realm_id FROM user_auth_realm WHERE user_id = ? ORDER BY realm_id', - array($target_user_id)); + [$target_user_id]); if (!is_array($rows)) { - return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'); + return ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed']; } - $actual_realm_ids = array(); + $actual_realm_ids = []; + foreach ($rows as $row) { if (!isset($row['realm_id']) || !is_numeric($row['realm_id'])) { - return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'); + return ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed']; } $actual_realm_ids[] = (int) $row['realm_id']; @@ -560,26 +577,26 @@ function audit_verify_operation($verifier) { sort($actual_realm_ids, SORT_NUMERIC); if ($actual_realm_ids === $expected_realm_ids) { - return array('outcome' => 'success', 'reason' => 'realm_permissions_verified'); + return ['outcome' => 'success', 'reason' => 'realm_permissions_verified']; } - return array('outcome' => 'failure', 'reason' => 'realm_permissions_mismatch'); + return ['outcome' => 'failure', 'reason' => 'realm_permissions_mismatch']; } function audit_finalize_request($id, $started_at = null, $verifier = null) { - $status_code = http_response_code(); - $status_code = is_int($status_code) ? $status_code : 200; + $status_code = http_response_code(); + $status_code = is_int($status_code) ? $status_code : 200; $request_status = audit_request_status(error_get_last(), $status_code); - $outcome = $request_status == 'failed' ? 'failure' : 'unknown'; + $outcome = $request_status == 'failed' ? 'failure' : 'unknown'; $outcome_reason = $request_status == 'failed' ? 'request_failed' : null; if ($request_status == 'completed' && $verifier !== null) { - $verification = audit_verify_operation($verifier); - $outcome = $verification['outcome']; + $verification = audit_verify_operation($verifier); + $outcome = $verification['outcome']; $outcome_reason = $verification['reason']; } - $duration_ms = $started_at === null ? null : max(0, (int) round((microtime(true) - $started_at) * 1000)); + $duration_ms = $started_at === null ? null : max(0, (int) round((microtime(true) - $started_at) * 1000)); $completed_time = audit_utc_time(); db_execute_prepared("UPDATE audit_log @@ -591,19 +608,20 @@ function audit_finalize_request($id, $started_at = null, $verifier = null) { duration_ms = ? WHERE id = ? AND request_status = 'started'", - array($request_status, $outcome_reason, $outcome, $status_code, $completed_time, $duration_ms, $id)); + [$request_status, $outcome_reason, $outcome, $status_code, $completed_time, $duration_ms, $id]); + + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); - $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id)); if (cacti_sizeof($event)) { db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', - array(audit_event_integrity_hash($event), $id)); + [audit_event_integrity_hash($event), $id]); } audit_deliver_external_event($id); audit_enqueue_syslog_event($id); } -function audit_record_event($event_type, $options = array()) { +function audit_record_event($event_type, $options = []) { if (read_config_option('audit_enabled') != 'on') { return 0; } @@ -615,7 +633,7 @@ function audit_record_event($event_type, $options = array()) { $event_suffix = strrchr($event_type, '.'); $action = $options['action'] ?? ($event_suffix === false ? $event_type : substr($event_suffix, 1)); $event_time = $options['event_time'] ?? audit_utc_time(); - $details = audit_json_encode(audit_redact_sensitive_data($options['details'] ?? array())); + $details = audit_json_encode(audit_redact_sensitive_data($options['details'] ?? [])); $external = read_config_option('audit_log_external') == 'on'; $ip_address = $options['ip_address'] ?? (function_exists('get_client_addr') ? get_client_addr() : ''); $user_agent = $options['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? ''); @@ -627,7 +645,7 @@ function audit_record_event($event_type, $options = array()) { operation_outcome, outcome_reason, http_method, http_status, completed_time, duration_ms, details ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - array( + [ $page, $user_id, $action, 'completed', $ip_address, $user_agent, $event_time, '{}', '[]', $external ? 'pending' : 'disabled', $event_uuid, $correlation_id, $event_type, $options['event_category'] ?? 'security', @@ -637,13 +655,14 @@ function audit_record_event($event_type, $options = array()) { $options['http_method'] ?? ($_SERVER['REQUEST_METHOD'] ?? null), $options['http_status'] ?? null, $options['completed_time'] ?? $event_time, $options['duration_ms'] ?? 0, $details - )); + ]); $id = db_fetch_insert_id(); - $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id)); + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); + if (cacti_sizeof($event)) { db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', - array(audit_event_integrity_hash($event), $id)); + [audit_event_integrity_hash($event), $id]); } audit_deliver_external_event($id); audit_enqueue_syslog_event($id); @@ -655,15 +674,15 @@ function audit_logout_pre_session_destroy() { $reason = get_nfilter_request_var('action', 'user'); $type = $reason == 'timeout' ? 'authentication.session.expired' : 'authentication.logout'; - audit_record_event($type, array( + audit_record_event($type, [ 'event_category' => 'authentication', - 'action' => $reason == 'timeout' ? 'timeout' : 'logout', - 'details' => array('reason' => $reason) - )); + 'action' => $reason == 'timeout' ? 'timeout' : 'logout', + 'details' => ['reason' => $reason] + ]); } function audit_enforce_syslog_settings_request() { - $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); + $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); $method = $_SERVER['REQUEST_METHOD'] ?? ''; if ($page !== 'settings.php' || $method !== 'POST') { @@ -671,15 +690,18 @@ function audit_enforce_syslog_settings_request() { } $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW); - $post = is_array($post) ? $post : array(); + $post = is_array($post) ? $post : []; + if (($post['action'] ?? '') !== 'save' || ($post['tab'] ?? '') !== 'audit') { return; } $has_syslog_fields = false; + foreach ($post as $name => $value) { if (strpos((string) $name, 'audit_syslog_') === 0) { $has_syslog_fields = true; + break; } } @@ -689,48 +711,48 @@ function audit_enforce_syslog_settings_request() { } if (!audit_user_is_admin()) { - audit_record_event('audit.syslog.configuration.denied', array( - 'event_category' => 'audit', - 'severity' => 'warning', - 'action' => 'save', - 'target_type' => 'syslog_configuration', + audit_record_event('audit.syslog.configuration.denied', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'save', + 'target_type' => 'syslog_configuration', 'operation_outcome' => 'failure', - 'outcome_reason' => 'audit_admin_required' - )); + 'outcome_reason' => 'audit_admin_required' + ]); http_response_code(403); exit; } - $names = array( + $names = [ 'receiver', 'port', 'transport', 'format', 'facility', 'application', 'node_id', 'timeout', 'udp_max_size', 'retry_base', 'retry_max', 'max_attempts', 'batch_size', 'pending_age_warning', 'dead_letter_warning', 'tls_ca_file', 'tls_client_cert', 'tls_client_key' - ); - $overrides = array(); + ]; + $overrides = []; foreach ($names as $name) { - $setting = 'audit_syslog_' . $name; + $setting = 'audit_syslog_' . $name; $overrides[$name] = isset($post[$setting]) && is_scalar($post[$setting]) ? (string) $post[$setting] : ''; } - $config = audit_syslog_config($overrides); - $enabling = isset($post['audit_syslog_enabled']) && $post['audit_syslog_enabled'] === 'on'; + $config = audit_syslog_config($overrides); + $enabling = isset($post['audit_syslog_enabled']) && $post['audit_syslog_enabled'] === 'on'; $configuring = trim($overrides['receiver']) !== ''; if (($enabling || $configuring) && !$config['valid']) { - audit_record_event('audit.syslog.configuration.rejected', array( - 'event_category' => 'audit', - 'severity' => 'warning', - 'action' => 'save', - 'target_type' => 'syslog_configuration', + audit_record_event('audit.syslog.configuration.rejected', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'save', + 'target_type' => 'syslog_configuration', 'operation_outcome' => 'failure', - 'outcome_reason' => 'configuration_invalid', - 'details' => array('errors' => $config['errors']) - )); + 'outcome_reason' => 'configuration_invalid', + 'details' => ['errors' => $config['errors']] + ]); raise_message( 'audit_syslog_configuration', @@ -742,8 +764,6 @@ function audit_enforce_syslog_settings_request() { } } - - function audit_config_insert() { global $action, $config; @@ -751,29 +771,29 @@ function audit_config_insert() { if (audit_log_valid_event()) { $started_at = microtime(true); - /* prepare post */ + // prepare post $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW); - $post = is_array($post) ? $post : array(); + $post = is_array($post) ? $post : []; - /* remove unsafe variables */ + // remove unsafe variables unset($post['__csrf_magic']); unset($post['header']); $post = audit_redact_sensitive_data($post); - /* check if drp_action is present and update action accordingly */ + // check if drp_action is present and update action accordingly if (isset($post['drp_action']) && $post['drp_action'] == 1) { $action = 'delete'; - } else if (isset($post['drp_action']) && $post['drp_action'] == 4) { + } elseif (isset($post['drp_action']) && $post['drp_action'] == 4) { $action = 'disable'; } - /* sanitize and serialize selected items */ + // sanitize and serialize selected items if (isset($post['selected_items']) && is_string($post['selected_items'])) { - $selected_items = @unserialize(stripslashes($post['selected_items']), array('allowed_classes' => false)); - $selected_items = is_array($selected_items) ? $selected_items : array(); + $selected_items = @unserialize(stripslashes($post['selected_items']), ['allowed_classes' => false]); + $selected_items = is_array($selected_items) ? $selected_items : []; $drop_action = $post['drp_action'] ?? false; } else { - $selected_items = array(); + $selected_items = []; $drop_action = false; } @@ -784,10 +804,10 @@ function audit_config_insert() { $user_id = (isset($_SESSION['sess_user_id']) ? $_SESSION['sess_user_id'] : 0); $event_time = audit_utc_time($started_at); - /* Retrieve IP address */ + // Retrieve IP address $ip_address = get_client_addr(); - /* Get the User Agent */ + // Get the User Agent $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; if (empty($action) && isset_request_var('action')) { @@ -803,9 +823,11 @@ function audit_config_insert() { switch ($drop_action) { case 2: $action = 'Delete Device'; + break; case 1: $action = 'Create Device'; + break; } @@ -814,16 +836,18 @@ function audit_config_insert() { switch ($drop_action) { case 2: $action = 'Host Enabled'; + break; case 3: $action = 'Host Disabled'; + break; } break; } - $audit_log = read_config_option('audit_log_external_path'); + $audit_log = read_config_option('audit_log_external_path'); $external_logging = read_config_option('audit_log_external') == 'on'; $external_status = $external_logging ? 'pending' : 'disabled'; @@ -836,20 +860,20 @@ function audit_config_insert() { $event_uuid = audit_uuid_v4(); $correlation_id = audit_request_correlation_id(); $event_type = audit_event_type_for_request($page, $action); - $category = in_array($page, array('user_admin.php', 'user_group_admin.php'), true) ? 'identity_access' : 'configuration'; + $category = in_array($page, ['user_admin.php', 'user_group_admin.php'], true) ? 'identity_access' : 'configuration'; db_execute_prepared('INSERT INTO audit_log ( page, user_id, action, request_status, ip_address, user_agent, event_time, post, object_data, external_status, event_uuid, correlation_id, event_type, event_category, severity, actor_type, target_type, target_id, operation_outcome, http_method ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - array( + [ $page, $user_id, $action, 'started', $ip_address, $user_agent, $event_time, $post, $object_data, $external_status, $event_uuid, $correlation_id, $event_type, $category, 'info', $user_id ? 'user' : 'system', preg_replace('/\.php$/', '', $page), $target_id, 'unknown', $_SERVER['REQUEST_METHOD'] ?? null - )); + ]); $audit_id = db_fetch_insert_id(); register_shutdown_function('audit_finalize_request', $audit_id, $started_at, $verifier); @@ -861,6 +885,7 @@ function audit_config_insert() { if ($external_logging && $audit_log != '' && !file_exists($audit_log)) { if (is_writable(dirname($audit_log))) { cacti_log(sprintf('NOTE: The Audit Log file \'%s\' does not exist. Creating it.', $audit_log), false, 'AUDIT'); + if (!touch($audit_log)) { cacti_log(sprintf('ERROR: Unable to create Audit Log file \'%s\'.', $audit_log), false, 'AUDIT'); } else { @@ -882,18 +907,17 @@ function audit_config_insert() { $page = basename($arguments[0]); $user_id = 0; $action = 'cli'; - $ip_address = getHostByName(php_uname('n')); + $ip_address = gethostbyname(php_uname('n')); $user_agent = get_current_user(); $event_time = audit_utc_time($started_at); $post = implode(' ', $arguments); - /* don't insert poller records */ - if (strpos($arguments[0], 'poller') === false && - strpos($arguments[0], 'cmd.php') === false && - strpos($arguments[0], '/scripts/') === false && + // don't insert poller records + if (strpos($arguments[0], 'poller') === false && + strpos($arguments[0], 'cmd.php') === false && + strpos($arguments[0], '/scripts/') === false && strpos($arguments[0], 'script_server.php') === false && - strpos($arguments[0], '_process.php') === false) { - + strpos($arguments[0], '_process.php') === false) { $external_status = read_config_option('audit_log_external') == 'on' ? 'pending' : 'disabled'; db_execute_prepared('INSERT INTO audit_log ( page, user_id, action, request_status, ip_address, user_agent, event_time, @@ -901,12 +925,12 @@ function audit_config_insert() { event_category, severity, actor_type, target_type, target_id, operation_outcome ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - array( + [ $page, $user_id, $action, 'started', $ip_address, $user_agent, $event_time, $post, '[]', $external_status, audit_uuid_v4(), audit_request_correlation_id(), 'cacti.cli.executed', 'system', 'info', 'system', 'cli_command', $page, 'unknown' - )); + ]); $audit_id = db_fetch_insert_id(); register_shutdown_function('audit_finalize_request', $audit_id, $started_at); } diff --git a/audit_syslog.php b/audit_syslog.php index f73f406..d393fba 100644 --- a/audit_syslog.php +++ b/audit_syslog.php @@ -28,12 +28,15 @@ function audit_syslog_read_setting($name, $default) { function audit_syslog_bounded_integer($value, $default, $minimum, $maximum, &$errors, $name) { if (!is_scalar($value) || !preg_match('/^[0-9]+$/', (string) $value)) { $errors[] = $name . '_invalid'; + return $default; } $value = (int) $value; + if ($value < $minimum || $value > $maximum) { $errors[] = $name . '_out_of_range'; + return $default; } @@ -60,6 +63,7 @@ function audit_syslog_valid_receiver($receiver) { } $labels = explode('.', $receiver); + foreach ($labels as $label) { if ($label === '' || strlen($label) > 63 || !preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i', $label)) { @@ -87,93 +91,99 @@ function audit_syslog_validate_optional_file($path, $name, &$errors) { return $path; } -function audit_syslog_config($overrides = array()) { - $defaults = array( - 'receiver' => '', - 'port' => '', - 'transport' => 'udp', - 'format' => 'json', - 'facility' => 'local0', - 'application' => 'cacti-audit', - 'node_id' => php_uname('n'), - 'timeout' => '5', - 'udp_max_size' => '8192', - 'retry_base' => '30', - 'retry_max' => '3600', - 'max_attempts' => '10', - 'batch_size' => '100', +function audit_syslog_config($overrides = []) { + $defaults = [ + 'receiver' => '', + 'port' => '', + 'transport' => 'udp', + 'format' => 'json', + 'facility' => 'local0', + 'application' => 'cacti-audit', + 'node_id' => php_uname('n'), + 'timeout' => '5', + 'udp_max_size' => '8192', + 'retry_base' => '30', + 'retry_max' => '3600', + 'max_attempts' => '10', + 'batch_size' => '100', 'pending_age_warning' => '900', 'dead_letter_warning' => '1', - 'tls_ca_file' => '', - 'tls_client_cert' => '', - 'tls_client_key' => '' - ); - $values = array(); + 'tls_ca_file' => '', + 'tls_client_cert' => '', + 'tls_client_key' => '' + ]; + $values = []; foreach ($defaults as $name => $default) { - $setting = 'audit_syslog_' . $name; + $setting = 'audit_syslog_' . $name; $values[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : audit_syslog_read_setting($setting, $default); } - $errors = array(); + $errors = []; $receiver = trim((string) $values['receiver']); + if (!audit_syslog_valid_receiver($receiver)) { $errors[] = 'receiver_invalid'; } $transport = strtolower((string) $values['transport']); - if (!in_array($transport, array('udp', 'tcp', 'tls'), true)) { - $errors[] = 'transport_invalid'; + + if (!in_array($transport, ['udp', 'tcp', 'tls'], true)) { + $errors[] = 'transport_invalid'; $transport = 'udp'; } $format = strtolower((string) $values['format']); - if (!in_array($format, array('rfc5424', 'cef', 'json'), true)) { + + if (!in_array($format, ['rfc5424', 'cef', 'json'], true)) { $errors[] = 'format_invalid'; - $format = 'json'; + $format = 'json'; } $facility_map = audit_syslog_facilities(); - $facility = strtolower((string) $values['facility']); + $facility = strtolower((string) $values['facility']); + if (!isset($facility_map[$facility])) { $errors[] = 'facility_invalid'; $facility = 'local0'; } $application = trim((string) $values['application']); + if (!audit_syslog_valid_header_value($application, 48)) { - $errors[] = 'application_invalid'; + $errors[] = 'application_invalid'; $application = 'cacti-audit'; } $node_id = trim((string) $values['node_id']); + if (!audit_syslog_valid_header_value($node_id, 255)) { $errors[] = 'node_id_invalid'; - $node_id = 'cacti'; + $node_id = 'cacti'; } $port = trim((string) $values['port']) === '' ? ($transport === 'tls' ? 6514 : 514) : audit_syslog_bounded_integer($values['port'], $transport === 'tls' ? 6514 : 514, 1, 65535, $errors, 'port'); - $timeout = audit_syslog_bounded_integer($values['timeout'], 5, 1, 30, $errors, 'timeout'); - $udp_max_size = audit_syslog_bounded_integer($values['udp_max_size'], 8192, 512, 65507, $errors, 'udp_max_size'); - $retry_base = audit_syslog_bounded_integer($values['retry_base'], 30, 1, 3600, $errors, 'retry_base'); - $retry_max = audit_syslog_bounded_integer($values['retry_max'], 3600, 1, 86400, $errors, 'retry_max'); - $max_attempts = audit_syslog_bounded_integer($values['max_attempts'], 10, 1, 100, $errors, 'max_attempts'); - $batch_size = audit_syslog_bounded_integer($values['batch_size'], 100, 1, 1000, $errors, 'batch_size'); + $timeout = audit_syslog_bounded_integer($values['timeout'], 5, 1, 30, $errors, 'timeout'); + $udp_max_size = audit_syslog_bounded_integer($values['udp_max_size'], 8192, 512, 65507, $errors, 'udp_max_size'); + $retry_base = audit_syslog_bounded_integer($values['retry_base'], 30, 1, 3600, $errors, 'retry_base'); + $retry_max = audit_syslog_bounded_integer($values['retry_max'], 3600, 1, 86400, $errors, 'retry_max'); + $max_attempts = audit_syslog_bounded_integer($values['max_attempts'], 10, 1, 100, $errors, 'max_attempts'); + $batch_size = audit_syslog_bounded_integer($values['batch_size'], 100, 1, 1000, $errors, 'batch_size'); $pending_age_warning = audit_syslog_bounded_integer($values['pending_age_warning'], 900, 60, 604800, $errors, 'pending_age_warning'); $dead_letter_warning = audit_syslog_bounded_integer($values['dead_letter_warning'], 1, 1, 1000000, $errors, 'dead_letter_warning'); if ($retry_max < $retry_base) { - $errors[] = 'retry_max_less_than_base'; + $errors[] = 'retry_max_less_than_base'; $retry_max = $retry_base; } - $tls_ca_file = trim((string) $values['tls_ca_file']); + $tls_ca_file = trim((string) $values['tls_ca_file']); $tls_client_cert = trim((string) $values['tls_client_cert']); - $tls_client_key = trim((string) $values['tls_client_key']); + $tls_client_key = trim((string) $values['tls_client_key']); if ($transport === 'tls') { audit_syslog_validate_optional_file($tls_ca_file, 'tls_ca_file', $errors); @@ -185,69 +195,69 @@ function audit_syslog_config($overrides = array()) { } } - $config = array( - 'receiver' => $receiver, - 'port' => $port, - 'transport' => $transport, - 'format' => $format, - 'facility' => $facility, - 'application' => $application, - 'node_id' => $node_id, - 'timeout' => $timeout, - 'udp_max_size' => $udp_max_size, - 'retry_base' => $retry_base, - 'retry_max' => $retry_max, - 'max_attempts' => $max_attempts, - 'batch_size' => $batch_size, - 'pending_age_warning' => $pending_age_warning, - 'dead_letter_warning' => $dead_letter_warning, - 'tls_ca_file' => $tls_ca_file, - 'tls_client_cert' => $tls_client_cert, - 'tls_client_key' => $tls_client_key, - 'poller_id' => defined('POLLER_ID') ? (string) POLLER_ID : '', - 'tls_verify_peer' => true, + $config = [ + 'receiver' => $receiver, + 'port' => $port, + 'transport' => $transport, + 'format' => $format, + 'facility' => $facility, + 'application' => $application, + 'node_id' => $node_id, + 'timeout' => $timeout, + 'udp_max_size' => $udp_max_size, + 'retry_base' => $retry_base, + 'retry_max' => $retry_max, + 'max_attempts' => $max_attempts, + 'batch_size' => $batch_size, + 'pending_age_warning' => $pending_age_warning, + 'dead_letter_warning' => $dead_letter_warning, + 'tls_ca_file' => $tls_ca_file, + 'tls_client_cert' => $tls_client_cert, + 'tls_client_key' => $tls_client_key, + 'poller_id' => defined('POLLER_ID') ? (string) POLLER_ID : '', + 'tls_verify_peer' => true, 'tls_verify_peer_name' => true, - 'errors' => array_values(array_unique($errors)) - ); - $config['valid'] = empty($config['errors']); + 'errors' => array_values(array_unique($errors)) + ]; + $config['valid'] = empty($config['errors']); $config['fingerprint'] = audit_syslog_destination_fingerprint($config); return $config; } function audit_syslog_destination_fingerprint($config) { - $identity = array( - 'receiver' => $config['receiver'], - 'port' => (int) $config['port'], - 'transport' => $config['transport'], - 'format' => $config['format'], - 'facility' => $config['facility'], - 'application' => $config['application'], - 'node_id' => $config['node_id'], - 'tls_ca_file' => $config['tls_ca_file'], + $identity = [ + 'receiver' => $config['receiver'], + 'port' => (int) $config['port'], + 'transport' => $config['transport'], + 'format' => $config['format'], + 'facility' => $config['facility'], + 'application' => $config['application'], + 'node_id' => $config['node_id'], + 'tls_ca_file' => $config['tls_ca_file'], 'tls_client_cert' => $config['tls_client_cert'] - ); + ]; return hash('sha256', audit_json_encode($identity, JSON_UNESCAPED_SLASHES)); } function audit_syslog_facilities() { - return array( - 'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3, - 'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7, - 'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11, - 'ntp' => 12, 'audit' => 13, 'alert' => 14, 'clock' => 15, + return [ + 'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3, + 'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7, + 'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11, + 'ntp' => 12, 'audit' => 13, 'alert' => 14, 'clock' => 15, 'local0' => 16, 'local1' => 17, 'local2' => 18, 'local3' => 19, 'local4' => 20, 'local5' => 21, 'local6' => 22, 'local7' => 23 - ); + ]; } function audit_syslog_severity_code($severity) { - $map = array( + $map = [ 'emergency' => 0, 'emerg' => 0, 'alert' => 1, 'critical' => 2, - 'crit' => 2, 'error' => 3, 'err' => 3, 'warning' => 4, - 'warn' => 4, 'notice' => 5, 'info' => 6, 'debug' => 7 - ); + 'crit' => 2, 'error' => 3, 'err' => 3, 'warning' => 4, + 'warn' => 4, 'notice' => 5, 'info' => 6, 'debug' => 7 + ]; $severity = strtolower((string) $severity); return isset($map[$severity]) ? $map[$severity] : 6; @@ -263,11 +273,12 @@ function audit_syslog_header_token($value, $maximum, $fallback) { function audit_syslog_structured_value($value) { $value = preg_replace('/[\\x00-\\x1f\\x7f]/', ' ', (string) $value); - return str_replace(array('\\', '"', ']'), array('\\\\', '\\"', '\\]'), $value); + return str_replace(['\\', '"', ']'], ['\\\\', '\\"', '\\]'], $value); } function audit_syslog_timestamp($value) { $value = (string) $value; + if (preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]([0-9]{2}:[0-9]{2}:[0-9]{2})(\\.[0-9]{1,6})?/', $value, $matches)) { return $matches[1] . 'T' . $matches[2] . (isset($matches[3]) ? $matches[3] : '') . 'Z'; } @@ -276,32 +287,32 @@ function audit_syslog_timestamp($value) { } function audit_syslog_normalized_data($event, $config) { - $data = audit_external_event_data($event); - $data['node_id'] = $config['node_id']; + $data = audit_external_event_data($event); + $data['node_id'] = $config['node_id']; $data['poller_id'] = $config['poller_id'] !== '' ? $config['poller_id'] : null; return $data; } function audit_syslog_cef_escape_header($value) { - return str_replace(array('\\', '|', "\r", "\n"), array('\\\\', '\\|', ' ', ' '), (string) $value); + return str_replace(['\\', '|', "\r", "\n"], ['\\\\', '\\|', ' ', ' '], (string) $value); } function audit_syslog_cef_escape_extension($value) { return str_replace( - array('\\', '=', "\r", "\n"), - array('\\\\', '\\=', '\\r', '\\n'), + ['\\', '=', "\r", "\n"], + ['\\\\', '\\=', '\\r', '\\n'], (string) $value ); } function audit_syslog_cef_severity($severity) { - $map = array( + $map = [ 'emergency' => 10, 'emerg' => 10, 'alert' => 10, - 'critical' => 9, 'crit' => 9, 'error' => 8, 'err' => 8, - 'warning' => 6, 'warn' => 6, 'notice' => 5, - 'info' => 3, 'debug' => 1 - ); + 'critical' => 9, 'crit' => 9, 'error' => 8, 'err' => 8, + 'warning' => 6, 'warn' => 6, 'notice' => 5, + 'info' => 3, 'debug' => 1 + ]; $severity = strtolower((string) $severity); return isset($map[$severity]) ? $map[$severity] : 3; @@ -310,6 +321,7 @@ function audit_syslog_cef_severity($severity) { function audit_syslog_cef_event_field($value) { if (is_string($value) && $value !== '') { $decoded = audit_json_decode($value, $error); + if ($error === null) { $value = $decoded; } @@ -335,7 +347,7 @@ function audit_syslog_cef_event_field($value) { function audit_syslog_cef_payload($event, $config) { $severity = audit_syslog_cef_severity($event['severity'] ?? 'info'); - $header = array( + $header = [ 'CEF:0', 'Cacti', 'Audit Plugin', @@ -343,30 +355,30 @@ function audit_syslog_cef_payload($event, $config) { $event['event_type'] ?? 'cacti.audit', $event['action'] ?? 'audit', $severity - ); - $extension = array( + ]; + $extension = [ 'externalId' => $event['event_uuid'] ?? '', - 'suser' => $event['user_id'] ?? '', - 'src' => $event['ip_address'] ?? '', - 'act' => $event['action'] ?? '', - 'outcome' => $event['operation_outcome'] ?? '', - 'cs1Label' => 'Correlation ID', - 'cs1' => $event['correlation_id'] ?? '', - 'cs2Label' => 'Target', - 'cs2' => trim(($event['target_type'] ?? '') . ':' . ($event['target_id'] ?? ''), ':'), - 'cs3Label' => 'Node ID', - 'cs3' => $config['node_id'], - 'cn1Label' => 'Poller ID', - 'cn1' => $config['poller_id'], - 'cs4Label' => 'Submitted Data', - 'cs4' => audit_syslog_cef_event_field($event['post'] ?? ''), - 'cs5Label' => 'Object Data', - 'cs5' => audit_syslog_cef_event_field($event['object_data'] ?? ''), - 'cs6Label' => 'Details', - 'cs6' => audit_syslog_cef_event_field($event['details'] ?? '') - ); - $encoded_header = array_map('audit_syslog_cef_escape_header', $header); - $encoded_extension = array(); + 'suser' => $event['user_id'] ?? '', + 'src' => $event['ip_address'] ?? '', + 'act' => $event['action'] ?? '', + 'outcome' => $event['operation_outcome'] ?? '', + 'cs1Label' => 'Correlation ID', + 'cs1' => $event['correlation_id'] ?? '', + 'cs2Label' => 'Target', + 'cs2' => trim(($event['target_type'] ?? '') . ':' . ($event['target_id'] ?? ''), ':'), + 'cs3Label' => 'Node ID', + 'cs3' => $config['node_id'], + 'cn1Label' => 'Poller ID', + 'cn1' => $config['poller_id'], + 'cs4Label' => 'Submitted Data', + 'cs4' => audit_syslog_cef_event_field($event['post'] ?? ''), + 'cs5Label' => 'Object Data', + 'cs5' => audit_syslog_cef_event_field($event['object_data'] ?? ''), + 'cs6Label' => 'Details', + 'cs6' => audit_syslog_cef_event_field($event['details'] ?? '') + ]; + $encoded_header = array_map('audit_syslog_cef_escape_header', $header); + $encoded_extension = []; foreach ($extension as $name => $value) { $encoded_extension[] = $name . '=' . audit_syslog_cef_escape_extension($value); @@ -389,35 +401,35 @@ function audit_syslog_message_payload($event, $config) { function audit_syslog_record($event, $config) { if (empty($config['valid'])) { - return array( - 'status' => 'failed', - 'permanent' => true, + return [ + 'status' => 'failed', + 'permanent' => true, 'error_code' => 'configuration_invalid', - 'error' => implode(',', $config['errors']), - 'record' => '' - ); + 'error' => implode(',', $config['errors']), + 'record' => '' + ]; } $facilities = audit_syslog_facilities(); - $priority = ($facilities[$config['facility']] * 8) + + $priority = ($facilities[$config['facility']] * 8) + audit_syslog_severity_code($event['severity'] ?? 'info'); - $timestamp = audit_syslog_timestamp($event['event_time'] ?? ''); - $hostname = audit_syslog_header_token($config['node_id'], 255, 'cacti'); - $application = audit_syslog_header_token($config['application'], 48, 'cacti-audit'); - $poller_id = $config['poller_id'] !== '' ? $config['poller_id'] : '-'; - $process = audit_syslog_header_token($poller_id, 128, '-'); - $message_id = audit_syslog_header_token($event['event_type'] ?? 'cacti.audit', 32, 'cacti.audit'); - $structured_values = array( - 'eventUuid' => $event['event_uuid'] ?? '', + $timestamp = audit_syslog_timestamp($event['event_time'] ?? ''); + $hostname = audit_syslog_header_token($config['node_id'], 255, 'cacti'); + $application = audit_syslog_header_token($config['application'], 48, 'cacti-audit'); + $poller_id = $config['poller_id'] !== '' ? $config['poller_id'] : '-'; + $process = audit_syslog_header_token($poller_id, 128, '-'); + $message_id = audit_syslog_header_token($event['event_type'] ?? 'cacti.audit', 32, 'cacti.audit'); + $structured_values = [ + 'eventUuid' => $event['event_uuid'] ?? '', 'correlationId' => $event['correlation_id'] ?? '', - 'nodeId' => $config['node_id'], - 'pollerId' => $config['poller_id'], - 'outcome' => $event['operation_outcome'] ?? '', - 'targetType' => $event['target_type'] ?? '', - 'targetId' => $event['target_id'] ?? '', + 'nodeId' => $config['node_id'], + 'pollerId' => $config['poller_id'], + 'outcome' => $event['operation_outcome'] ?? '', + 'targetType' => $event['target_type'] ?? '', + 'targetId' => $event['target_id'] ?? '', 'integrityHash' => $event['integrity_hash'] ?? '' - ); - $structured = array(); + ]; + $structured = []; foreach ($structured_values as $name => $value) { $structured[] = $name . '="' . audit_syslog_structured_value($value) . '"'; @@ -429,32 +441,32 @@ function audit_syslog_record($event, $config) { audit_syslog_message_payload($event, $config); if (strlen($record) > 262144) { - return array( - 'status' => 'failed', - 'permanent' => true, + return [ + 'status' => 'failed', + 'permanent' => true, 'error_code' => 'message_too_large', - 'error' => 'The formatted Syslog record exceeds 262144 bytes.', - 'record' => '' - ); + 'error' => 'The formatted Syslog record exceeds 262144 bytes.', + 'record' => '' + ]; } if ($config['transport'] === 'udp' && strlen($record) > $config['udp_max_size']) { - return array( - 'status' => 'failed', - 'permanent' => true, + return [ + 'status' => 'failed', + 'permanent' => true, 'error_code' => 'udp_message_too_large', - 'error' => 'The formatted Syslog record exceeds the configured UDP maximum.', - 'record' => '' - ); + 'error' => 'The formatted Syslog record exceeds the configured UDP maximum.', + 'record' => '' + ]; } - return array( - 'status' => 'ready', - 'permanent' => false, + return [ + 'status' => 'ready', + 'permanent' => false, 'error_code' => '', - 'error' => '', - 'record' => $record - ); + 'error' => '', + 'record' => $record + ]; } function audit_syslog_frame($record, $transport) { @@ -467,6 +479,7 @@ function audit_syslog_frame($record, $transport) { function audit_syslog_socket_target($config) { $receiver = $config['receiver']; + if (filter_var($receiver, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { $receiver = '[' . $receiver . ']'; } @@ -478,8 +491,9 @@ function audit_syslog_socket_target($config) { function audit_syslog_stream_operation($operation, &$warning = null) { $warning = ''; - $handler = function($severity, $message) use (&$warning) { + $handler = function ($severity, $message) use (&$warning) { $warning = audit_syslog_bounded_error($message); + return true; }; @@ -489,6 +503,7 @@ function audit_syslog_stream_operation($operation, &$warning = null) { return call_user_func($operation); } catch (Throwable $exception) { $warning = audit_syslog_bounded_error($exception->getMessage()); + return false; } finally { restore_error_handler(); @@ -496,17 +511,17 @@ function audit_syslog_stream_operation($operation, &$warning = null) { } function audit_syslog_open_socket($config) { - $context_options = array(); + $context_options = []; if ($config['transport'] === 'tls') { - $context_options['ssl'] = array( - 'verify_peer' => true, - 'verify_peer_name' => true, - 'allow_self_signed' => false, - 'peer_name' => $config['receiver'], - 'SNI_enabled' => true, + $context_options['ssl'] = [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'allow_self_signed' => false, + 'peer_name' => $config['receiver'], + 'SNI_enabled' => true, 'disable_compression' => true - ); + ]; if ($config['tls_ca_file'] !== '') { $context_options['ssl']['cafile'] = $config['tls_ca_file']; @@ -514,16 +529,16 @@ function audit_syslog_open_socket($config) { if ($config['tls_client_cert'] !== '') { $context_options['ssl']['local_cert'] = $config['tls_client_cert']; - $context_options['ssl']['local_pk'] = $config['tls_client_key']; + $context_options['ssl']['local_pk'] = $config['tls_client_key']; } } - $context = stream_context_create($context_options); - $error_number = 0; - $error_message = ''; - $flags = STREAM_CLIENT_CONNECT; + $context = stream_context_create($context_options); + $error_number = 0; + $error_message = ''; + $flags = STREAM_CLIENT_CONNECT; $stream_warning = ''; - $socket = audit_syslog_stream_operation(function() use ( + $socket = audit_syslog_stream_operation(function () use ( $config, &$error_number, &$error_message, @@ -545,17 +560,17 @@ function audit_syslog_open_socket($config) { ? $error_message : ($stream_warning !== '' ? $stream_warning : 'Unable to connect to Syslog receiver.'); - return array( - 'socket' => null, + return [ + 'socket' => null, 'error_code' => 'connection_failed', - 'error' => audit_syslog_bounded_error($error) - ); + 'error' => audit_syslog_bounded_error($error) + ]; } stream_set_timeout($socket, $config['timeout']); stream_set_blocking($socket, true); - return array('socket' => $socket, 'error_code' => '', 'error' => ''); + return ['socket' => $socket, 'error_code' => '', 'error' => '']; } function audit_syslog_bounded_error($error) { @@ -565,25 +580,26 @@ function audit_syslog_bounded_error($error) { } function audit_syslog_fwrite($socket, $message, &$warning = null) { - return audit_syslog_stream_operation(function() use ($socket, $message) { + return audit_syslog_stream_operation(function () use ($socket, $message) { return fwrite($socket, $message); }, $warning); } function audit_syslog_write($socket, $message, $transport) { if (!is_resource($socket)) { - return array('status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.'); + return ['status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.']; } if ($transport === 'udp') { $stream_warning = ''; - $written = audit_syslog_fwrite($socket, $message, $stream_warning); + $written = audit_syslog_fwrite($socket, $message, $stream_warning); + if ($written !== strlen($message)) { $error = $stream_warning !== '' ? $stream_warning : 'Unable to write the complete Syslog datagram.'; - return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => $error); + return ['status' => 'failed', 'error_code' => 'write_failed', 'error' => $error]; } } else { $length = strlen($message); @@ -591,45 +607,48 @@ function audit_syslog_write($socket, $message, $transport) { while ($offset < $length) { $stream_warning = ''; - $written = audit_syslog_fwrite($socket, substr($message, $offset), $stream_warning); + $written = audit_syslog_fwrite($socket, substr($message, $offset), $stream_warning); + if ($written === false || $written === 0) { $metadata = stream_get_meta_data($socket); - $error = !empty($metadata['timed_out']) + $error = !empty($metadata['timed_out']) ? 'Syslog write timed out.' : ($stream_warning !== '' ? $stream_warning : 'Unable to write the complete Syslog record.'); - return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => $error); + return ['status' => 'failed', 'error_code' => 'write_failed', 'error' => $error]; } $offset += $written; } } - return array('status' => 'sent_unconfirmed', 'error_code' => '', 'error' => ''); + return ['status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '']; } function audit_syslog_send_event($event, $config, &$socket = null) { $formatted = audit_syslog_record($event, $config); + if ($formatted['status'] !== 'ready') { return $formatted; } if (!is_resource($socket)) { $opened = audit_syslog_open_socket($config); + if (!is_resource($opened['socket'])) { - return array( - 'status' => 'failed', - 'permanent' => false, + return [ + 'status' => 'failed', + 'permanent' => false, 'error_code' => $opened['error_code'], - 'error' => $opened['error'] - ); + 'error' => $opened['error'] + ]; } $socket = $opened['socket']; } - $message = audit_syslog_frame($formatted['record'], $config['transport']); - $result = audit_syslog_write($socket, $message, $config['transport']); + $message = audit_syslog_frame($formatted['record'], $config['transport']); + $result = audit_syslog_write($socket, $message, $config['transport']); $result['permanent'] = false; if ($result['status'] !== 'sent_unconfirmed' && is_resource($socket)) { @@ -648,24 +667,24 @@ function audit_enqueue_syslog_event($audit_id) { $event = db_fetch_row_prepared('SELECT id, event_uuid, request_status FROM audit_log WHERE id = ?', - array($audit_id)); + [$audit_id]); if (!cacti_sizeof($event) || $event['request_status'] === 'started' || $event['event_uuid'] === '') { return; } $config = audit_syslog_config(); - $state = $config['valid'] ? 'pending' : 'dead_letter'; - $error = $config['valid'] ? null : audit_syslog_bounded_error('configuration_invalid: ' . implode(',', $config['errors'])); + $state = $config['valid'] ? 'pending' : 'dead_letter'; + $error = $config['valid'] ? null : audit_syslog_bounded_error('configuration_invalid: ' . implode(',', $config['errors'])); db_execute_prepared('INSERT IGNORE INTO audit_syslog_delivery ( audit_id, event_uuid, destination_fingerprint, node_id, poller_id, state, attempts, next_attempt, last_error, created_time, updated_time ) VALUES (?, ?, ?, ?, ?, ?, 0, UTC_TIMESTAMP(6), ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))', - array( + [ $event['id'], $event['event_uuid'], $config['fingerprint'], $config['node_id'], $config['poller_id'], $state, $error - )); + ]); } function audit_syslog_delivery_config($config, $delivery) { @@ -684,14 +703,14 @@ function audit_syslog_delivery_config($config, $delivery) { function audit_syslog_retry_delay($attempt, $config) { $exponent = min(max(0, (int) $attempt - 1), 30); - $delay = $config['retry_base'] * pow(2, $exponent); + $delay = $config['retry_base'] * pow(2, $exponent); return (int) min($config['retry_max'], $delay); } function audit_syslog_update_delivery($delivery, $result, $config) { $attempts = (int) $delivery['attempts'] + 1; - $error = isset($result['error']) ? audit_syslog_bounded_error($result['error']) : ''; + $error = isset($result['error']) ? audit_syslog_bounded_error($result['error']) : ''; if ($result['status'] === 'sent_unconfirmed') { db_execute_prepared("UPDATE audit_syslog_delivery @@ -703,14 +722,15 @@ function audit_syslog_update_delivery($delivery, $result, $config) { last_error = NULL, updated_time = UTC_TIMESTAMP(6) WHERE id = ?", - array($config['fingerprint'], $attempts, $delivery['delivery_id'])); + [$config['fingerprint'], $attempts, $delivery['delivery_id']]); + return; } - $permanent = !empty($result['permanent']) || $attempts >= $config['max_attempts']; - $state = $permanent ? 'dead_letter' : 'retry'; - $delay = $permanent ? 0 : audit_syslog_retry_delay($attempts, $config); - $error_code = isset($result['error_code']) ? $result['error_code'] : 'delivery_failed'; + $permanent = !empty($result['permanent']) || $attempts >= $config['max_attempts']; + $state = $permanent ? 'dead_letter' : 'retry'; + $delay = $permanent ? 0 : audit_syslog_retry_delay($attempts, $config); + $error_code = isset($result['error_code']) ? $result['error_code'] : 'delivery_failed'; $stored_error = audit_syslog_bounded_error($error_code . ': ' . $error); db_execute_prepared('UPDATE audit_syslog_delivery @@ -722,7 +742,7 @@ function audit_syslog_update_delivery($delivery, $result, $config) { last_error = ?, updated_time = UTC_TIMESTAMP(6) WHERE id = ?', - array($state, $config['fingerprint'], $attempts, $delay, $delay, $stored_error, $delivery['delivery_id'])); + [$state, $config['fingerprint'], $attempts, $delay, $delay, $stored_error, $delivery['delivery_id']]); } function audit_process_syslog_queue() { @@ -731,8 +751,10 @@ function audit_process_syslog_queue() { } $config = audit_syslog_config(); + if (!$config['valid']) { audit_syslog_check_health($config); + return; } @@ -749,12 +771,12 @@ function audit_process_syslog_queue() { AND a.request_status <> 'started' ORDER BY d.next_attempt, d.id LIMIT " . $batch_size, - array()); + []); $socket = null; foreach ($deliveries as $delivery) { $delivery_config = audit_syslog_delivery_config($config, $delivery); - $result = audit_syslog_send_event($delivery, $delivery_config, $socket); + $result = audit_syslog_send_event($delivery, $delivery_config, $socket); audit_syslog_update_delivery($delivery, $result, $delivery_config); if ($result['status'] !== 'sent_unconfirmed' && empty($result['permanent'])) { @@ -771,11 +793,11 @@ function audit_process_syslog_queue() { function audit_syslog_health() { if (!db_table_exists('audit_syslog_delivery')) { - return array( - 'pending' => 0, 'retry' => 0, 'sent_unconfirmed' => 0, - 'dead_letter' => 0, 'oldest_pending_seconds' => 0, + return [ + 'pending' => 0, 'retry' => 0, 'sent_unconfirmed' => 0, + 'dead_letter' => 0, 'oldest_pending_seconds' => 0, 'last_attempt' => null, 'last_sent' => null, 'last_error' => null - ); + ]; } $row = db_fetch_row("SELECT @@ -795,16 +817,16 @@ function audit_syslog_health() { ORDER BY last_attempt DESC, id DESC LIMIT 1"); - return array( - 'pending' => (int) ($row['pending'] ?? 0), - 'retry' => (int) ($row['retry'] ?? 0), - 'sent_unconfirmed' => (int) ($row['sent_unconfirmed'] ?? 0), - 'dead_letter' => (int) ($row['dead_letter'] ?? 0), + return [ + 'pending' => (int) ($row['pending'] ?? 0), + 'retry' => (int) ($row['retry'] ?? 0), + 'sent_unconfirmed' => (int) ($row['sent_unconfirmed'] ?? 0), + 'dead_letter' => (int) ($row['dead_letter'] ?? 0), 'oldest_pending_seconds' => (int) ($row['oldest_pending_seconds'] ?? 0), - 'last_attempt' => $row['last_attempt'] ?? null, - 'last_sent' => $row['last_sent'] ?? null, - 'last_error' => $last_error !== false && $last_error !== '' ? $last_error : null - ); + 'last_attempt' => $row['last_attempt'] ?? null, + 'last_sent' => $row['last_sent'] ?? null, + 'last_error' => $last_error !== false && $last_error !== '' ? $last_error : null + ]; } function audit_syslog_check_health($config = null) { @@ -812,12 +834,12 @@ function audit_syslog_check_health($config = null) { return; } - $config = $config === null ? audit_syslog_config() : $config; - $health = audit_syslog_health(); + $config = $config === null ? audit_syslog_config() : $config; + $health = audit_syslog_health(); $unhealthy = !$config['valid'] || $health['dead_letter'] >= $config['dead_letter_warning'] || $health['oldest_pending_seconds'] >= $config['pending_age_warning']; - $state = $unhealthy ? 'unhealthy' : 'healthy'; + $state = $unhealthy ? 'unhealthy' : 'healthy'; $previous = read_config_option('audit_syslog_health_state'); if ($previous !== $state) { @@ -829,19 +851,22 @@ function audit_syslog_check_health($config = null) { } } -function audit_syslog_retry_dead_letters($delivery_ids = array()) { +function audit_syslog_retry_dead_letters($delivery_ids = []) { if (!db_table_exists('audit_syslog_delivery')) { return 0; } - $normalized_ids = array(); + $normalized_ids = []; + foreach ($delivery_ids as $delivery_id) { $delivery_id = (int) $delivery_id; + if ($delivery_id > 0) { $normalized_ids[] = $delivery_id; } } $delivery_ids = array_values(array_unique($normalized_ids)); + if (cacti_sizeof($delivery_ids) > 1000) { $delivery_ids = array_slice($delivery_ids, 0, 1000); } @@ -872,22 +897,22 @@ function audit_syslog_retry_dead_letters($delivery_ids = array()) { function audit_syslog_test_delivery() { $config = audit_syslog_config(); - $event = array( - 'event_uuid' => audit_uuid_v4(), - 'correlation_id' => audit_request_correlation_id(), - 'event_type' => 'audit.syslog.test', - 'event_category' => 'audit', - 'severity' => 'notice', - 'user_id' => $_SESSION['sess_user_id'] ?? 0, - 'action' => 'test', - 'request_status' => 'completed', + $event = [ + 'event_uuid' => audit_uuid_v4(), + 'correlation_id' => audit_request_correlation_id(), + 'event_type' => 'audit.syslog.test', + 'event_category' => 'audit', + 'severity' => 'notice', + 'user_id' => $_SESSION['sess_user_id'] ?? 0, + 'action' => 'test', + 'request_status' => 'completed', 'operation_outcome' => 'success', - 'target_type' => 'syslog_receiver', - 'target_id' => $config['receiver'], - 'ip_address' => function_exists('get_client_addr') ? get_client_addr() : '', - 'event_time' => audit_utc_time(), - 'details' => audit_json_encode(array('test' => true)) - ); + 'target_type' => 'syslog_receiver', + 'target_id' => $config['receiver'], + 'ip_address' => function_exists('get_client_addr') ? get_client_addr() : '', + 'event_time' => audit_utc_time(), + 'details' => audit_json_encode(['test' => true]) + ]; $socket = null; $result = audit_syslog_send_event($event, $config, $socket); diff --git a/index.php b/index.php index 2e22b8c..828ecbe 100644 --- a/index.php +++ b/index.php @@ -22,4 +22,4 @@ +-------------------------------------------------------------------------+ */ -header("Location:../index.php"); +header('Location:../index.php'); diff --git a/phpstan/stubs/cacti.stubs.php b/phpstan/stubs/cacti.stubs.php index 8e38993..7d24700 100644 --- a/phpstan/stubs/cacti.stubs.php +++ b/phpstan/stubs/cacti.stubs.php @@ -1,6 +1,6 @@ */ global $config; @@ -88,294 +88,240 @@ /** @var array */ global $item_rows; -/* ----- Plugin / Hook API --------------------------------------------- */ +// ----- Plugin / Hook API --------------------------------------------- -function api_plugin_register_hook(string $plugin, string $hook, string $function, string $file): void -{ +function api_plugin_register_hook(string $plugin, string $hook, string $function, string $file): void { } -function api_plugin_register_realm(string $plugin, array $file, array $display, int $install = 0): void -{ +function api_plugin_register_realm(string $plugin, array $file, array $display, int $install = 0): void { } -function api_plugin_is_enabled(string $plugin): bool -{ +function api_plugin_is_enabled(string $plugin): bool { return false; } -function api_plugin_enable_hooks(string $plugin): void -{ +function api_plugin_enable_hooks(string $plugin): void { } -function api_plugin_replicate_config(): void -{ +function api_plugin_replicate_config(): void { } -function api_plugin_user_realm_auth(string $file = ''): bool -{ +function api_plugin_user_realm_auth(string $file = ''): bool { return false; } -function auth_augment_roles(string $label, array $files): void -{ +function auth_augment_roles(string $label, array $files): void { } -/* ----- Database ------------------------------------------------------- */ +// ----- Database ------------------------------------------------------- -function db_execute(string $sql, bool $log = true, mixed $db_conn = false): bool -{ +function db_execute(string $sql, bool $log = true, mixed $db_conn = false): bool { return false; } -function db_execute_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false, bool $execute_prepared = true): bool -{ +function db_execute_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false, bool $execute_prepared = true): bool { return false; } /** * @return array>|false */ -function db_fetch_assoc(string $sql, bool $log = true, mixed $db_conn = false): array|false -{ +function db_fetch_assoc(string $sql, bool $log = true, mixed $db_conn = false): array|false { return false; } /** * @return array>|false */ -function db_fetch_assoc_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false): array|false -{ +function db_fetch_assoc_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false): array|false { return false; } /** * @return array|false */ -function db_fetch_row(string $sql, bool $log = true, mixed $db_conn = false): array|false -{ +function db_fetch_row(string $sql, bool $log = true, mixed $db_conn = false): array|false { return false; } /** * @return array|false */ -function db_fetch_row_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false): array|false -{ +function db_fetch_row_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false): array|false { return false; } -function db_fetch_cell(string $sql, string $col_name = '', bool $log = true, mixed $db_conn = false): mixed -{ +function db_fetch_cell(string $sql, string $col_name = '', bool $log = true, mixed $db_conn = false): mixed { return false; } -function db_fetch_cell_prepared(string $sql, array $params = [], string $col_name = '', bool $log = true, mixed $db_conn = false): mixed -{ +function db_fetch_cell_prepared(string $sql, array $params = [], string $col_name = '', bool $log = true, mixed $db_conn = false): mixed { return false; } -function db_fetch_insert_id(string $table = ''): int -{ +function db_fetch_insert_id(string $table = ''): int { return 0; } -function db_affected_rows(mixed $db_conn = false): int -{ +function db_affected_rows(mixed $db_conn = false): int { return 0; } -function db_column_exists(string $table, string $column, bool $log = true, mixed $db_conn = false): bool -{ +function db_column_exists(string $table, string $column, bool $log = true, mixed $db_conn = false): bool { return false; } -function db_table_exists(string $table, bool $log = true, mixed $db_conn = false): bool -{ +function db_table_exists(string $table, bool $log = true, mixed $db_conn = false): bool { return false; } -function db_index_exists(string $table, string $index, bool $log = true, mixed $db_conn = false): bool -{ +function db_index_exists(string $table, string $index, bool $log = true, mixed $db_conn = false): bool { return false; } -function db_add_index(string $table, string $type, string $name, string $definition, bool $log = true, mixed $db_conn = false): void -{ +function db_add_index(string $table, string $type, string $name, string $definition, bool $log = true, mixed $db_conn = false): void { } -/* ----- Config / Options --------------------------------------------- */ +// ----- Config / Options --------------------------------------------- -function read_config_option(string $name, bool $global = false): mixed -{ +function read_config_option(string $name, bool $global = false): mixed { return false; } -function set_config_option(string $name, string $value): bool -{ +function set_config_option(string $name, string $value): bool { return false; } -/* ----- Localization -------------------------------------------------- */ +// ----- Localization -------------------------------------------------- -function __(string $text, string $domain = ''): string -{ +function __(string $text, string $domain = ''): string { return $text; } /** * @param mixed ...$args */ -function __esc(string $text, string $domain = ''): string -{ +function __esc(string $text, string $domain = ''): string { return $text; } -/* ----- Input Handling ------------------------------------------------ */ +// ----- Input Handling ------------------------------------------------ -function get_request_var(string $name, mixed $default = '', string $filter = ''): mixed -{ +function get_request_var(string $name, mixed $default = '', string $filter = ''): mixed { return $default; } -function get_filter_request_var(string $name, int $filter = FILTER_DEFAULT, array $options = []): mixed -{ +function get_filter_request_var(string $name, int $filter = FILTER_DEFAULT, array $options = []): mixed { return false; } -function get_nfilter_request_var(string $name, mixed $default = '', string $filter = ''): mixed -{ +function get_nfilter_request_var(string $name, mixed $default = '', string $filter = ''): mixed { return $default; } -function isset_request_var(string $name): bool -{ +function isset_request_var(string $name): bool { return false; } -function isempty_request_var(string $name): bool -{ +function isempty_request_var(string $name): bool { return false; } -function set_default_action(string $default = ''): void -{ +function set_default_action(string $default = ''): void { } /** * @param array $filters */ -function validate_store_request_vars(array $filters, string $sess_prefix = ''): void -{ +function validate_store_request_vars(array $filters, string $sess_prefix = ''): void { } -function sanitize_search_string(mixed $string): string -{ +function sanitize_search_string(mixed $string): string { return ''; } -function html_escape_request_var(string $name): void -{ +function html_escape_request_var(string $name): void { } -/* ----- Logging / Misc ------------------------------------------------ */ +// ----- Logging / Misc ------------------------------------------------ -function cacti_log(string $string, bool $output = false, string $environment = '', int $level = 1, bool $force = false): bool -{ +function cacti_log(string $string, bool $output = false, string $environment = '', int $level = 1, bool $force = false): bool { return false; } -function cacti_sizeof(mixed $array): int -{ +function cacti_sizeof(mixed $array): int { return is_array($array) ? count($array) : 0; } -function get_client_addr(): string -{ +function get_client_addr(): string { return ''; } -function get_username(int $user_id): string -{ +function get_username(int $user_id): string { return ''; } -function raise_message(string $id, string $message = '', int $level = 0): void -{ +function raise_message(string $id, string $message = '', int $level = 0): void { } -function csrf_check(bool $force = false): bool -{ +function csrf_check(bool $force = false): bool { return false; } -/* ----- UI / HTML Helpers --------------------------------------------- */ +// ----- UI / HTML Helpers --------------------------------------------- -function top_header(): void -{ +function top_header(): void { } -function bottom_footer(): void -{ +function bottom_footer(): void { } -function html_start_box(string $title, string $width, string $align = '', string $add = '', string $collapsible = '', string $url = ''): void -{ +function html_start_box(string $title, string $width, string $align = '', string $add = '', string $collapsible = '', string $url = ''): void { } -function html_end_box(bool $trailing_br = true): void -{ +function html_end_box(bool $trailing_br = true): void { } /** * @param array ...$args */ -function html_nav_bar(string $base_url, int $max_pages, int $current_page, int $rows_per_page, int $total_rows, int $pages_to_display, string $title, string $page_var = 'page', string $class = 'main'): string -{ +function html_nav_bar(string $base_url, int $max_pages, int $current_page, int $rows_per_page, int $total_rows, int $pages_to_display, string $title, string $page_var = 'page', string $class = 'main'): string { return ''; } /** * @param array $display_text */ -function html_header_sort(array $display_text, string $sort_column, string $sort_direction, bool $last_column = false): void -{ +function html_header_sort(array $display_text, string $sort_column, string $sort_direction, bool $last_column = false): void { } -function html_escape(string $string): string -{ +function html_escape(string $string): string { return ''; } -function form_alternate_row(string $id, bool $light = false): void -{ +function form_alternate_row(string $id, bool $light = false): void { } -function form_selectable_cell(string $text, int $id, string $class = '', string $title = ''): void -{ +function form_selectable_cell(string $text, int $id, string $class = '', string $title = ''): void { } -function form_selectable_ecell(string $text, int $id): void -{ +function form_selectable_ecell(string $text, int $id): void { } -function form_end_row(): void -{ +function form_end_row(): void { } -function filter_value(string $text, string $filter): string -{ +function filter_value(string $text, string $filter): string { return ''; } -function get_order_string(): string -{ +function get_order_string(): string { return ''; } /** * @param array>|false $array */ -function array_rekey(array|false $array, string $key, string $key_value): array -{ +function array_rekey(array|false $array, string $key, string $key_value): array { return []; -} \ No newline at end of file +} diff --git a/setup.php b/setup.php index 7d74976..076d429 100644 --- a/setup.php +++ b/setup.php @@ -34,7 +34,7 @@ function plugin_audit_install() { api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php'); api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php'); - /* hook for table replication */ + // hook for table replication api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php'); audit_setup_realms(true); @@ -43,10 +43,10 @@ function plugin_audit_install() { } function audit_setup_realms($grant_installing_user = false) { - $realms = array( + $realms = [ 'audit.php' => __('Audit Log User', 'audit'), 'audit_manage.php' => __('Audit Log Admin', 'audit') - ); + ]; foreach ($realms as $file => $display) { api_plugin_register_realm('audit', $file, $display, $grant_installing_user ? 1 : 0); @@ -60,13 +60,13 @@ function audit_setup_realms($grant_installing_user = false) { FROM plugin_realms WHERE plugin = ? AND file IN (?, ?)', - array('audit', 'audit.php', 'audit_manage.php')); + ['audit', 'audit.php', 'audit_manage.php']); foreach ($realm_ids as $realm) { db_execute_prepared('REPLACE INTO user_auth_realm (user_id, realm_id) VALUES (?, ?)', - array($admin_user, $realm['realm_id'])); + [$admin_user, $realm['realm_id']]); } } } @@ -77,22 +77,22 @@ function audit_remove_deprecated_realms() { FROM plugin_realms WHERE plugin = ? AND file = ?', - array('audit', 'audit_purge.php')); + ['audit', 'audit_purge.php']); foreach ($realms as $realm) { $realm_id = $realm['id'] + 100; db_execute_prepared('DELETE FROM user_auth_realm WHERE realm_id = ?', - array($realm_id)); + [$realm_id]); db_execute_prepared('DELETE FROM user_auth_group_realm WHERE realm_id = ?', - array($realm_id)); + [$realm_id]); db_execute_prepared('DELETE FROM plugin_realms WHERE id = ?', - array($realm['id'])); + [$realm['id']]); } if (cacti_sizeof($realms)) { @@ -103,6 +103,7 @@ function audit_remove_deprecated_realms() { function plugin_audit_uninstall() { db_execute('DROP TABLE IF EXISTS audit_syslog_delivery'); db_execute('DROP TABLE IF EXISTS audit_log'); + return true; } @@ -127,21 +128,24 @@ function audit_check_upgrade() { include_once($config['library_path'] . '/database.php'); include_once($config['library_path'] . '/functions.php'); - $files = array('plugins.php', 'audit.php'); - if (isset($_SERVER['PHP_SELF']) && !in_array(basename($_SERVER['PHP_SELF']), $files)) { + $files = ['plugins.php', 'audit.php']; + + if (isset($_SERVER['PHP_SELF']) && !in_array(basename($_SERVER['PHP_SELF']), $files, true)) { return; } $info = plugin_audit_version(); $current = $info['version']; - $old = db_fetch_cell_prepared('SELECT version FROM plugin_config WHERE directory = ?', array('audit')); + $old = db_fetch_cell_prepared('SELECT version FROM plugin_config WHERE directory = ?', ['audit']); + if ($current != $old) { if (api_plugin_is_enabled('audit')) { - # may sound ridiculous, but enables new hooks + // may sound ridiculous, but enables new hooks api_plugin_enable_hooks('audit'); } db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS object_data LONGBLOB'); + if (db_column_exists('audit_log', 'outcome')) { if (!db_column_exists('audit_log', 'request_status')) { db_execute("ALTER TABLE audit_log CHANGE COLUMN outcome request_status varchar(20) NOT NULL DEFAULT 'unknown'"); @@ -168,7 +172,7 @@ function audit_check_upgrade() { db_execute_prepared('UPDATE plugin_config SET version = ? WHERE directory = ?', - array($current, 'audit')); + [$current, 'audit']); db_execute_prepared('UPDATE plugin_config SET version = ?, @@ -176,9 +180,9 @@ function audit_check_upgrade() { author = ?, webpage = ? WHERE directory = ?', - array($info['version'], $info['longname'], $info['author'], $info['homepage'], $info['name'])); + [$info['version'], $info['longname'], $info['author'], $info['homepage'], $info['name']]); - /* hook for table replication */ + // hook for table replication api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php', '1'); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php', 1); api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php', 1); @@ -210,6 +214,7 @@ function audit_replicate_out($data) { } db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS object_data LONGBLOB', true, $rcnn_id); + if (db_column_exists('audit_log', 'outcome', false, $rcnn_id)) { if (!db_column_exists('audit_log', 'request_status', false, $rcnn_id)) { db_execute("ALTER TABLE audit_log CHANGE COLUMN outcome request_status varchar(20) NOT NULL DEFAULT 'unknown'", true, $rcnn_id); @@ -255,7 +260,7 @@ function audit_poller_bottom() { WHERE audit_syslog_delivery.audit_id = audit_log.id AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter') )", - array($cutoff->format('Y-m-d H:i:s'))); + [$cutoff->format('Y-m-d H:i:s')]); $rows = db_affected_rows(); cacti_log('NOTE: Purged ' . $rows . ' Audit Log Records from Cacti', false, 'POLLER'); } @@ -350,51 +355,51 @@ function audit_setup_syslog_table() { db_execute("ALTER TABLE audit_syslog_delivery ADD COLUMN IF NOT EXISTS node_id varchar(255) NOT NULL DEFAULT 'cacti' AFTER destination_fingerprint"); - db_execute("ALTER TABLE audit_syslog_delivery + db_execute('ALTER TABLE audit_syslog_delivery ADD COLUMN IF NOT EXISTS poller_id varchar(64) DEFAULT NULL - AFTER node_id"); + AFTER node_id'); } function audit_upgrade_event_schema($rcnn_id = false) { - $remote = $rcnn_id !== false; - $args = $remote ? array(true, $rcnn_id) : array(); - $columns = array( - "event_uuid char(36) DEFAULT NULL", - "correlation_id char(36) DEFAULT NULL", + $remote = $rcnn_id !== false; + $args = $remote ? [true, $rcnn_id] : []; + $columns = [ + 'event_uuid char(36) DEFAULT NULL', + 'correlation_id char(36) DEFAULT NULL', "event_type varchar(100) NOT NULL DEFAULT 'cacti.request'", "event_category varchar(40) NOT NULL DEFAULT 'configuration'", "severity varchar(12) NOT NULL DEFAULT 'info'", "actor_type varchar(20) NOT NULL DEFAULT 'user'", - "target_type varchar(64) DEFAULT NULL", - "target_id varchar(128) DEFAULT NULL", + 'target_type varchar(64) DEFAULT NULL', + 'target_id varchar(128) DEFAULT NULL', "operation_outcome varchar(20) NOT NULL DEFAULT 'unknown'", - "outcome_reason varchar(255) DEFAULT NULL", - "http_method varchar(10) DEFAULT NULL", - "http_status smallint unsigned DEFAULT NULL", - "completed_time datetime(6) DEFAULT NULL", - "duration_ms bigint unsigned DEFAULT NULL", - "details longblob", - "previous_hash char(64) DEFAULT NULL", - "integrity_hash char(64) DEFAULT NULL", - "external_attempts int unsigned NOT NULL DEFAULT 0", - "external_last_attempt datetime(6) DEFAULT NULL", - "external_delivered_time datetime(6) DEFAULT NULL" - ); + 'outcome_reason varchar(255) DEFAULT NULL', + 'http_method varchar(10) DEFAULT NULL', + 'http_status smallint unsigned DEFAULT NULL', + 'completed_time datetime(6) DEFAULT NULL', + 'duration_ms bigint unsigned DEFAULT NULL', + 'details longblob', + 'previous_hash char(64) DEFAULT NULL', + 'integrity_hash char(64) DEFAULT NULL', + 'external_attempts int unsigned NOT NULL DEFAULT 0', + 'external_last_attempt datetime(6) DEFAULT NULL', + 'external_delivered_time datetime(6) DEFAULT NULL' + ]; foreach ($columns as $definition) { call_user_func_array('db_execute', array_merge( - array('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS ' . $definition), + ['ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS ' . $definition], $args )); } - $indexes = array( - 'event_uuid' => array('UNIQUE INDEX', array('event_uuid')), - 'correlation_id' => array('INDEX', array('correlation_id')), - 'event_type' => array('INDEX', array('event_type')), - 'operation_outcome' => array('INDEX', array('operation_outcome')), - 'external_status' => array('INDEX', array('external_status')) - ); + $indexes = [ + 'event_uuid' => ['UNIQUE INDEX', ['event_uuid']], + 'correlation_id' => ['INDEX', ['correlation_id']], + 'event_type' => ['INDEX', ['event_type']], + 'operation_outcome' => ['INDEX', ['operation_outcome']], + 'external_status' => ['INDEX', ['external_status']] + ]; foreach ($indexes as $name => $definition) { if (!db_index_exists('audit_log', $name, false, $remote ? $rcnn_id : false)) { @@ -406,6 +411,7 @@ function audit_upgrade_event_schema($rcnn_id = false) { function plugin_audit_version() { global $config; $info = parse_ini_file($config['base_path'] . '/plugins/audit/INFO', true); + return $info['info']; } @@ -450,12 +456,12 @@ function audit_utilities_array() { if (api_plugin_user_realm_auth('audit.php')) { $utilities[__('Technical Support', 'audit')] = array_merge( $utilities[__('Technical Support', 'audit')], - array( - __('View Audit Log', 'audit') => array( - 'link' => 'plugins/audit/audit.php', + [ + __('View Audit Log', 'audit') => [ + 'link' => 'plugins/audit/audit.php', 'description' => __('Allows Administrators to view change activity on the Cacti server. Administrators can also export the audit log for analysis purposes.', 'audit') - ) - ) + ] + ] ); } } @@ -465,10 +471,10 @@ function audit_config_arrays() { global $menu, $messages, $audit_retentions, $utilities; if (isset($_SESSION['audit_message']) && $_SESSION['audit_message'] != '') { - $messages['audit_message'] = array('message' => $_SESSION['audit_message'], 'type' => 'info'); + $messages['audit_message'] = ['message' => $_SESSION['audit_message'], 'type' => 'info']; } - $audit_retentions = array( + $audit_retentions = [ -1 => __('Indefinitely', 'audit'), 14 => __('%d Weeks', 2, 'audit'), 30 => __('%d Month', 1, 'audit'), @@ -479,12 +485,12 @@ function audit_config_arrays() { 365 => __('%d Year', 1, 'audit'), 730 => __('%d Years', 2, 'audit'), 1095 => __('%d Years', 3, 'audit') - ); + ]; $menu[__('Utilities')]['plugins/audit/audit.php'] = __('Audit Log', 'audit'); if (function_exists('auth_augment_roles')) { - auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php')); + auth_augment_roles(__('Audit Plugin', 'audit'), ['audit.php', 'audit_manage.php']); } audit_check_upgrade(); @@ -493,221 +499,222 @@ function audit_config_arrays() { function audit_config_settings() { global $tabs, $settings, $item_rows, $audit_retentions; - $temp = array( - 'audit_header' => array( + $temp = [ + 'audit_header' => [ 'friendly_name' => __('Audit Log Settings', 'audit'), - 'method' => 'spacer', - ), - 'audit_enabled' => array( + 'method' => 'spacer', + ], + 'audit_enabled' => [ 'friendly_name' => __('Enable Audit Log', 'audit'), - 'description' => __('Check this box, if you want the Audit Log to track GUI activities.', 'audit'), - 'method' => 'checkbox', - 'default' => 'on' - ), - 'audit_retention' => array( + 'description' => __('Check this box, if you want the Audit Log to track GUI activities.', 'audit'), + 'method' => 'checkbox', + 'default' => 'on' + ], + 'audit_retention' => [ 'friendly_name' => __('Audit Log Retention', 'audit'), - 'description' => __('How long do you wish Audit Log entries to be retained?', 'audit'), - 'method' => 'drop_array', - 'default' => '90', - 'array' => $audit_retentions - ), - 'audit_log_external' => array( + 'description' => __('How long do you wish Audit Log entries to be retained?', 'audit'), + 'method' => 'drop_array', + 'default' => '90', + 'array' => $audit_retentions + ], + 'audit_log_external' => [ 'friendly_name' => __('External Audit Log', 'audit'), - 'description' => __('Check this box, if you want the Audit Log to be written to an external file.', 'audit'), - 'method' => 'checkbox', - 'default' => 'off' - ), - 'audit_log_external_format' => array( + 'description' => __('Check this box, if you want the Audit Log to be written to an external file.', 'audit'), + 'method' => 'checkbox', + 'default' => 'off' + ], + 'audit_log_external_format' => [ 'friendly_name' => __('External Audit Log Format', 'audit'), - 'description' => __('Select the output format for external audit log records.', 'audit'), - 'method' => 'drop_array', - 'default' => 'json', - 'array' => array( + 'description' => __('Select the output format for external audit log records.', 'audit'), + 'method' => 'drop_array', + 'default' => 'json', + 'array' => [ 'text' => __('Text', 'audit'), 'json' => __('JSON', 'audit') - ) - ), - 'audit_log_external_path' => array( + ] + ], + 'audit_log_external_path' => [ 'friendly_name' => __('External Audit Log Log file Path', 'audit'), - 'description' => __('Enter the path to the external audit log file.', 'audit'), - 'method' => 'filepath', - 'default' => '/var/www/html/cacti/log/audit.log', - 'max_length' => '255' - ), - ); + 'description' => __('Enter the path to the external audit log file.', 'audit'), + 'method' => 'filepath', + 'default' => '/var/www/html/cacti/log/audit.log', + 'max_length' => '255' + ], + ]; if (php_sapi_name() === 'cli' || audit_user_is_admin()) { - $facility_options = array(); + $facility_options = []; + foreach (audit_syslog_facilities() as $facility => $code) { $facility_options[$facility] = strtoupper($facility) . ' (' . $code . ')'; } - $syslog = array( - 'audit_syslog_header' => array( + $syslog = [ + 'audit_syslog_header' => [ 'friendly_name' => __('Remote Syslog', 'audit'), - 'method' => 'spacer' - ), - 'audit_syslog_enabled' => array( + 'method' => 'spacer' + ], + 'audit_syslog_enabled' => [ 'friendly_name' => __('Enable Remote Syslog', 'audit'), - 'description' => __('Queue finalized audit events for remote Syslog delivery. The existing external file output remains independent.', 'audit'), - 'method' => 'checkbox', - 'default' => 'off' - ), - 'audit_syslog_receiver' => array( + 'description' => __('Queue finalized audit events for remote Syslog delivery. The existing external file output remains independent.', 'audit'), + 'method' => 'checkbox', + 'default' => 'off' + ], + 'audit_syslog_receiver' => [ 'friendly_name' => __('Syslog Receiver', 'audit'), - 'description' => __('Enter a receiver hostname or IP address without a URI scheme, path, or credentials.', 'audit'), - 'method' => 'textbox', - 'default' => '', - 'max_length' => '253', - 'size' => '60' - ), - 'audit_syslog_port' => array( + 'description' => __('Enter a receiver hostname or IP address without a URI scheme, path, or credentials.', 'audit'), + 'method' => 'textbox', + 'default' => '', + 'max_length' => '253', + 'size' => '60' + ], + 'audit_syslog_port' => [ 'friendly_name' => __('Syslog Port', 'audit'), - 'description' => __('Enter 1-65535, or leave blank to use 514 for UDP/TCP and 6514 for TLS.', 'audit'), - 'method' => 'textbox', - 'default' => '', - 'max_length' => '5', - 'size' => '8' - ), - 'audit_syslog_transport' => array( + 'description' => __('Enter 1-65535, or leave blank to use 514 for UDP/TCP and 6514 for TLS.', 'audit'), + 'method' => 'textbox', + 'default' => '', + 'max_length' => '5', + 'size' => '8' + ], + 'audit_syslog_transport' => [ 'friendly_name' => __('Syslog Transport', 'audit'), - 'description' => __('UDP sends one datagram without acknowledgement. TCP and TLS use RFC 6587 octet-count framing.', 'audit'), - 'method' => 'drop_array', - 'default' => 'udp', - 'array' => array( + 'description' => __('UDP sends one datagram without acknowledgement. TCP and TLS use RFC 6587 octet-count framing.', 'audit'), + 'method' => 'drop_array', + 'default' => 'udp', + 'array' => [ 'udp' => __('UDP', 'audit'), 'tcp' => __('TCP', 'audit'), 'tls' => __('TLS', 'audit') - ) - ), - 'audit_syslog_format' => array( + ] + ], + 'audit_syslog_format' => [ 'friendly_name' => __('Syslog Payload Format', 'audit'), - 'description' => __('All formats use an RFC 5424 header. Select RFC 5424 structured data, CEF, or compact JSON for the message.', 'audit'), - 'method' => 'drop_array', - 'default' => 'json', - 'array' => array( + 'description' => __('All formats use an RFC 5424 header. Select RFC 5424 structured data, CEF, or compact JSON for the message.', 'audit'), + 'method' => 'drop_array', + 'default' => 'json', + 'array' => [ 'rfc5424' => __('RFC 5424', 'audit'), - 'cef' => __('CEF', 'audit'), - 'json' => __('JSON', 'audit') - ) - ), - 'audit_syslog_facility' => array( + 'cef' => __('CEF', 'audit'), + 'json' => __('JSON', 'audit') + ] + ], + 'audit_syslog_facility' => [ 'friendly_name' => __('Syslog Facility', 'audit'), - 'description' => __('Select the facility used to calculate the RFC 5424 priority.', 'audit'), - 'method' => 'drop_array', - 'default' => 'local0', - 'array' => $facility_options - ), - 'audit_syslog_application' => array( + 'description' => __('Select the facility used to calculate the RFC 5424 priority.', 'audit'), + 'method' => 'drop_array', + 'default' => 'local0', + 'array' => $facility_options + ], + 'audit_syslog_application' => [ 'friendly_name' => __('Syslog Application Name', 'audit'), - 'description' => __('RFC 5424 APP-NAME. Printable non-space ASCII, up to 48 characters.', 'audit'), - 'method' => 'textbox', - 'default' => 'cacti-audit', - 'max_length' => '48', - 'size' => '30' - ), - 'audit_syslog_node_id' => array( + 'description' => __('RFC 5424 APP-NAME. Printable non-space ASCII, up to 48 characters.', 'audit'), + 'method' => 'textbox', + 'default' => 'cacti-audit', + 'max_length' => '48', + 'size' => '30' + ], + 'audit_syslog_node_id' => [ 'friendly_name' => __('Audit Node Identity', 'audit'), - 'description' => __('Stable RFC 5424 hostname identity for this Cacti node. Do not use a value that changes on restart.', 'audit'), - 'method' => 'textbox', - 'default' => php_uname('n'), - 'max_length' => '255', - 'size' => '60' - ), - 'audit_syslog_timeout' => array( + 'description' => __('Stable RFC 5424 hostname identity for this Cacti node. Do not use a value that changes on restart.', 'audit'), + 'method' => 'textbox', + 'default' => php_uname('n'), + 'max_length' => '255', + 'size' => '60' + ], + 'audit_syslog_timeout' => [ 'friendly_name' => __('Connection and Write Timeout', 'audit'), - 'description' => __('Timeout in seconds, from 1 through 30.', 'audit'), - 'method' => 'textbox', - 'default' => '5', - 'max_length' => '2', - 'size' => '8' - ), - 'audit_syslog_udp_max_size' => array( + 'description' => __('Timeout in seconds, from 1 through 30.', 'audit'), + 'method' => 'textbox', + 'default' => '5', + 'max_length' => '2', + 'size' => '8' + ], + 'audit_syslog_udp_max_size' => [ 'friendly_name' => __('Maximum UDP Record Size', 'audit'), - 'description' => __('Records larger than this byte limit are dead-lettered and are never truncated or split. TCP or TLS is recommended for large events.', 'audit'), - 'method' => 'textbox', - 'default' => '8192', - 'max_length' => '5', - 'size' => '10' - ), - 'audit_syslog_tls_header' => array( + 'description' => __('Records larger than this byte limit are dead-lettered and are never truncated or split. TCP or TLS is recommended for large events.', 'audit'), + 'method' => 'textbox', + 'default' => '8192', + 'max_length' => '5', + 'size' => '10' + ], + 'audit_syslog_tls_header' => [ 'friendly_name' => __('Syslog TLS', 'audit'), - 'method' => 'spacer' - ), - 'audit_syslog_tls_ca_file' => array( + 'method' => 'spacer' + ], + 'audit_syslog_tls_ca_file' => [ 'friendly_name' => __('TLS CA File', 'audit'), - 'description' => __('Optional PEM CA bundle. Peer and hostname verification are always enabled.', 'audit'), - 'method' => 'filepath', - 'default' => '', - 'max_length' => '255' - ), - 'audit_syslog_tls_client_cert' => array( + 'description' => __('Optional PEM CA bundle. Peer and hostname verification are always enabled.', 'audit'), + 'method' => 'filepath', + 'default' => '', + 'max_length' => '255' + ], + 'audit_syslog_tls_client_cert' => [ 'friendly_name' => __('TLS Client Certificate', 'audit'), - 'description' => __('Optional PEM client certificate. A client key must also be configured.', 'audit'), - 'method' => 'filepath', - 'default' => '', - 'max_length' => '255' - ), - 'audit_syslog_tls_client_key' => array( + 'description' => __('Optional PEM client certificate. A client key must also be configured.', 'audit'), + 'method' => 'filepath', + 'default' => '', + 'max_length' => '255' + ], + 'audit_syslog_tls_client_key' => [ 'friendly_name' => __('TLS Client Private Key', 'audit'), - 'description' => __('Optional readable PEM private-key path. The key contents are never stored in audit events.', 'audit'), - 'method' => 'filepath', - 'default' => '', - 'max_length' => '255' - ), - 'audit_syslog_delivery_header' => array( + 'description' => __('Optional readable PEM private-key path. The key contents are never stored in audit events.', 'audit'), + 'method' => 'filepath', + 'default' => '', + 'max_length' => '255' + ], + 'audit_syslog_delivery_header' => [ 'friendly_name' => __('Syslog Delivery Queue', 'audit'), - 'method' => 'spacer' - ), - 'audit_syslog_retry_base' => array( + 'method' => 'spacer' + ], + 'audit_syslog_retry_base' => [ 'friendly_name' => __('Retry Base Delay', 'audit'), - 'description' => __('Initial retry delay in seconds, from 1 through 3600.', 'audit'), - 'method' => 'textbox', - 'default' => '30', - 'max_length' => '4', - 'size' => '8' - ), - 'audit_syslog_retry_max' => array( + 'description' => __('Initial retry delay in seconds, from 1 through 3600.', 'audit'), + 'method' => 'textbox', + 'default' => '30', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_syslog_retry_max' => [ 'friendly_name' => __('Maximum Retry Delay', 'audit'), - 'description' => __('Maximum retry delay in seconds, from the base delay through 86400.', 'audit'), - 'method' => 'textbox', - 'default' => '3600', - 'max_length' => '5', - 'size' => '8' - ), - 'audit_syslog_max_attempts' => array( + 'description' => __('Maximum retry delay in seconds, from the base delay through 86400.', 'audit'), + 'method' => 'textbox', + 'default' => '3600', + 'max_length' => '5', + 'size' => '8' + ], + 'audit_syslog_max_attempts' => [ 'friendly_name' => __('Maximum Delivery Attempts', 'audit'), - 'description' => __('Move a record to dead-letter after this many attempts, from 1 through 100.', 'audit'), - 'method' => 'textbox', - 'default' => '10', - 'max_length' => '3', - 'size' => '8' - ), - 'audit_syslog_batch_size' => array( + 'description' => __('Move a record to dead-letter after this many attempts, from 1 through 100.', 'audit'), + 'method' => 'textbox', + 'default' => '10', + 'max_length' => '3', + 'size' => '8' + ], + 'audit_syslog_batch_size' => [ 'friendly_name' => __('Poller Batch Size', 'audit'), - 'description' => __('Maximum due records processed per poller cycle, from 1 through 1000.', 'audit'), - 'method' => 'textbox', - 'default' => '100', - 'max_length' => '4', - 'size' => '8' - ), - 'audit_syslog_pending_age_warning' => array( + 'description' => __('Maximum due records processed per poller cycle, from 1 through 1000.', 'audit'), + 'method' => 'textbox', + 'default' => '100', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_syslog_pending_age_warning' => [ 'friendly_name' => __('Pending Age Warning', 'audit'), - 'description' => __('Show an unhealthy warning when the oldest queued record reaches this age in seconds.', 'audit'), - 'method' => 'textbox', - 'default' => '900', - 'max_length' => '6', - 'size' => '10' - ), - 'audit_syslog_dead_letter_warning' => array( + 'description' => __('Show an unhealthy warning when the oldest queued record reaches this age in seconds.', 'audit'), + 'method' => 'textbox', + 'default' => '900', + 'max_length' => '6', + 'size' => '10' + ], + 'audit_syslog_dead_letter_warning' => [ 'friendly_name' => __('Dead-letter Warning Count', 'audit'), - 'description' => __('Show an unhealthy warning when this many records are dead-lettered.', 'audit'), - 'method' => 'textbox', - 'default' => '1', - 'max_length' => '7', - 'size' => '10' - ) - ); + 'description' => __('Show an unhealthy warning when this many records are dead-lettered.', 'audit'), + 'method' => 'textbox', + 'default' => '1', + 'max_length' => '7', + 'size' => '10' + ] + ]; $temp = array_merge($temp, $syslog); } @@ -722,12 +729,12 @@ function audit_config_settings() { } function audit_draw_navigation_text($nav) { - $nav['audit.php:'] = array( + $nav['audit.php:'] = [ 'title' => __('Audit Event Log', 'audit'), 'mapping' => 'index.php:', 'url' => 'audit.php', 'level' => '1' - ); + ]; return $nav; } diff --git a/tests/Pest.php b/tests/Pest.php index e2132a3..675e214 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -7,8 +7,6 @@ +-------------------------------------------------------------------------+ */ -/* - * Pest configuration file. - */ +// Pest configuration file. require_once __DIR__ . '/bootstrap.php'; diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php index bd8d5e1..16b89b9 100644 --- a/tests/Security/Php74CompatibilityTest.php +++ b/tests/Security/Php74CompatibilityTest.php @@ -13,12 +13,12 @@ */ describe('PHP 7.4 compatibility in audit', function () { - $files = array( + $files = [ 'audit.php', 'audit_functions.php', 'audit_syslog.php', 'setup.php', - ); + ]; beforeEach(function () use ($files) { foreach ($files as $relativeFile) { diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index fbdc78d..9a0c8cc 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -14,13 +14,12 @@ describe('prepared statement consistency in audit', function () { it('documents database helper usage in all plugin files', function () { - $targetFiles = array( + $targetFiles = [ 'audit.php', 'audit_functions.php', 'audit_syslog.php', 'setup.php', - ); - + ]; foreach ($targetFiles as $relativeFile) { $path = realpath(__DIR__ . '/../../' . $relativeFile); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php index 7f4a129..a686c7b 100644 --- a/tests/Security/SetupStructureTest.php +++ b/tests/Security/SetupStructureTest.php @@ -7,9 +7,7 @@ +-------------------------------------------------------------------------+ */ -/* - * Verify setup.php defines required plugin hooks and info function. - */ +// Verify setup.php defines required plugin hooks and info function. describe('audit setup.php structure', function () { $source = file_get_contents(realpath(__DIR__ . '/../../setup.php')); @@ -26,11 +24,19 @@ expect($source)->toContain('function plugin_audit_uninstall'); }); - it('returns version array with name key', function () use ($source) { - expect($source)->toMatch('/[\'\""]name[\'\""]\s*=>/'); + it('reads plugin info from INFO file', function () use ($source) { + expect($source)->toContain("parse_ini_file"); + expect($source)->toContain("'info'"); }); - it('returns version array with version key', function () use ($source) { - expect($source)->toMatch('/[\'\""]version[\'\""]\s*=>/'); + it('INFO file defines name and version keys', function () { + $info_file = realpath(__DIR__ . '/../../INFO'); + expect($info_file)->not->toBeFalse('INFO file must exist'); + + $info = parse_ini_file($info_file, true); + expect($info)->not->toBeFalse('INFO file must be valid INI'); + expect($info)->toHaveKey('info'); + expect($info['info'])->toHaveKey('name'); + expect($info['info'])->toHaveKey('version'); }); }); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b701e11..e268ffd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,43 +12,45 @@ * can be loaded in isolation without the full Cacti application. */ -$GLOBALS['__test_db_calls'] = array(); +$GLOBALS['__test_db_calls'] = []; if (!function_exists('db_execute')) { function db_execute($sql) { - $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute', 'sql' => $sql, 'params' => array()); + $GLOBALS['__test_db_calls'][] = ['fn' => 'db_execute', 'sql' => $sql, 'params' => []]; + return true; } } if (!function_exists('db_execute_prepared')) { - function db_execute_prepared($sql, $params = array()) { - $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params); + function db_execute_prepared($sql, $params = []) { + $GLOBALS['__test_db_calls'][] = ['fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params]; + return true; } } if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { - return array(); + return []; } } if (!function_exists('db_fetch_assoc_prepared')) { - function db_fetch_assoc_prepared($sql, $params = array()) { - return array(); + function db_fetch_assoc_prepared($sql, $params = []) { + return []; } } if (!function_exists('db_fetch_row')) { function db_fetch_row($sql) { - return array(); + return []; } } if (!function_exists('db_fetch_row_prepared')) { - function db_fetch_row_prepared($sql, $params = array()) { - return array(); + function db_fetch_row_prepared($sql, $params = []) { + return []; } } @@ -59,7 +61,7 @@ function db_fetch_cell($sql) { } if (!function_exists('db_fetch_cell_prepared')) { - function db_fetch_cell_prepared($sql, $params = array()) { + function db_fetch_cell_prepared($sql, $params = []) { return ''; } } diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 2f445ae..80ba0c0 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -5,19 +5,19 @@ $javascript = file_get_contents(dirname(__DIR__) . '/js/functions.js'); $setup = file_get_contents(dirname(__DIR__) . '/setup.php'); -$required_controller_guards = array( +$required_controller_guards = [ "\$_SERVER['REQUEST_METHOD'] !== 'POST'", 'audit_user_is_admin()', 'csrf_check(false)', 'html_escape($data', "__('Outcome Reason:', 'audit')", "header('Content-Type: text/csv; charset=UTF-8')", - "fputcsv(", + 'fputcsv(', "case 'syslog_test':", "case 'syslog_retry':", 'audit_syslog_test_delivery()', 'audit_syslog_retry_dead_letters($delivery_ids)' -); +]; foreach ($required_controller_guards as $guard) { if (strpos($controller, $guard) === false) { @@ -26,13 +26,13 @@ } } -$required_schema_fragments = array( +$required_schema_fragments = [ "'audit.php' => __('Audit Log User'", "'audit_manage.php' => __('Audit Log Admin'", 'audit_setup_realms(true)', 'audit_setup_realms()', 'audit_remove_deprecated_realms()', - "auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'))", + "auth_augment_roles(__('Audit Plugin', 'audit'), ['audit.php', 'audit_manage.php'])", 'api_plugin_register_hook(\'audit\', \'replicate_out\'', 'request_status', 'ADD COLUMN IF NOT EXISTS external_status', @@ -46,8 +46,8 @@ 'external_attempts', 'CREATE TABLE IF NOT EXISTS `audit_syslog_delivery`', 'DROP TABLE IF EXISTS audit_syslog_delivery', - "auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'))" -); + "auth_augment_roles(__('Audit Plugin', 'audit'), ['audit.php', 'audit_manage.php'])" +]; foreach ($required_schema_fragments as $fragment) { if (strpos($setup, $fragment) === false) { @@ -56,12 +56,12 @@ } } -$required_verifier_fragments = array( +$required_verifier_fragments = [ 'audit_operation_verifier_for_request', "'user_realm_permissions'", "'realm_permissions_verified'", "register_shutdown_function('audit_finalize_request', \$audit_id, \$started_at, \$verifier)" -); +]; foreach ($required_verifier_fragments as $fragment) { if (strpos($functions, $fragment) === false) { @@ -91,7 +91,7 @@ } if (strpos($functions, 'audit_enforce_syslog_settings_request()') === false || - strpos($functions, "'audit.syslog.configuration.denied'") === false) { + strpos($functions, "'audit.syslog.configuration.denied'") === false) { fwrite(STDERR, 'Remote Syslog settings must enforce Audit Log Admin on save.' . PHP_EOL); exit(1); } @@ -107,7 +107,7 @@ } if (strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_test") === false || - strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_retry") === false) { + strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_retry") === false) { fwrite(STDERR, 'Syslog administration actions must use POST request paths.' . PHP_EOL); exit(1); } diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php index 4a736a5..2085182 100644 --- a/tests/security_functions_test.php +++ b/tests/security_functions_test.php @@ -2,10 +2,10 @@ require_once dirname(__DIR__) . '/audit_functions.php'; -$audit_test_realms = array(); -$audit_test_existing_users = array(); -$audit_test_user_realms = array(); -$audit_test_user_query_failure = false; +$audit_test_realms = []; +$audit_test_existing_users = []; +$audit_test_user_realms = []; +$audit_test_user_query_failure = false; $audit_test_realm_query_failure = false; function api_plugin_user_realm_auth($filename = '') { @@ -14,7 +14,7 @@ function api_plugin_user_realm_auth($filename = '') { return !empty($audit_test_realms[$filename]); } -function db_fetch_cell_prepared($sql, $params = array()) { +function db_fetch_cell_prepared($sql, $params = []) { global $audit_test_existing_users, $audit_test_user_query_failure; if ($audit_test_user_query_failure) { @@ -26,18 +26,18 @@ function db_fetch_cell_prepared($sql, $params = array()) { return in_array($user_id, $audit_test_existing_users, true) ? 1 : 0; } -function db_fetch_assoc_prepared($sql, $params = array()) { +function db_fetch_assoc_prepared($sql, $params = []) { global $audit_test_user_realms, $audit_test_realm_query_failure; if ($audit_test_realm_query_failure) { return false; } - $user_id = (int) ($params[0] ?? 0); - $realm_ids = $audit_test_user_realms[$user_id] ?? array(); + $user_id = (int) ($params[0] ?? 0); + $realm_ids = $audit_test_user_realms[$user_id] ?? []; - return array_map(function($realm_id) { - return array('realm_id' => $realm_id); + return array_map(function ($realm_id) { + return ['realm_id' => $realm_id]; }, $realm_ids); } @@ -53,90 +53,90 @@ function audit_test_assert_same($expected, $actual, $message) { audit_test_assert_same(false, audit_user_is_admin(), 'Audit users must not be treated as audit administrators.'); $audit_test_realms['audit_manage.php'] = true; audit_test_assert_same(true, audit_user_is_admin(), 'Audit plugin administrators must be able to purge.'); -$audit_test_realms = array(); +$audit_test_realms = []; -$verifier = audit_operation_verifier_for_request('user_admin.php', array( - 'action' => 'save', - 'id' => '4', +$verifier = audit_operation_verifier_for_request('user_admin.php', [ + 'action' => 'save', + 'id' => '4', 'save_component_realm_perms' => '1', - 'section110' => 'on', - 'section106' => 'on' -)); + 'section110' => 'on', + 'section106' => 'on' +]); audit_test_assert_same( - array( - 'type' => 'user_realm_permissions', - 'target_user_id' => 4, - 'expected_realm_ids' => array(106, 110) - ), + [ + 'type' => 'user_realm_permissions', + 'target_user_id' => 4, + 'expected_realm_ids' => [106, 110] + ], $verifier, 'User realm permission saves must capture a normalized post-condition verifier.' ); audit_test_assert_same( null, - audit_operation_verifier_for_request('host.php', array('id' => '4')), + audit_operation_verifier_for_request('host.php', ['id' => '4']), 'Unrelated requests must not receive a user realm verifier.' ); -$invalid_verifier = audit_operation_verifier_for_request('user_admin.php', array( - 'id' => 'invalid', +$invalid_verifier = audit_operation_verifier_for_request('user_admin.php', [ + 'id' => 'invalid', 'save_component_realm_perms' => '1' -)); +]); audit_test_assert_same( 'invalid', $invalid_verifier['type'], 'Invalid realm permission requests must not be verified as successful.' ); -$audit_test_existing_users = array(4); -$audit_test_user_realms = array(4 => array(110, 106)); +$audit_test_existing_users = [4]; +$audit_test_user_realms = [4 => [110, 106]]; audit_test_assert_same( - array('outcome' => 'success', 'reason' => 'realm_permissions_verified'), + ['outcome' => 'success', 'reason' => 'realm_permissions_verified'], audit_verify_operation($verifier), 'Matching stored realm permissions must produce a verified success outcome.' ); -$audit_test_user_realms = array(4 => array(106)); +$audit_test_user_realms = [4 => [106]]; audit_test_assert_same( - array('outcome' => 'failure', 'reason' => 'realm_permissions_mismatch'), + ['outcome' => 'failure', 'reason' => 'realm_permissions_mismatch'], audit_verify_operation($verifier), 'Mismatched stored realm permissions must produce a failure outcome.' ); -$audit_test_existing_users = array(); +$audit_test_existing_users = []; audit_test_assert_same( - array('outcome' => 'failure', 'reason' => 'target_user_not_found'), + ['outcome' => 'failure', 'reason' => 'target_user_not_found'], audit_verify_operation($verifier), 'A missing target user must produce a failure outcome.' ); -$audit_test_existing_users = array(4); +$audit_test_existing_users = [4]; $audit_test_user_query_failure = true; audit_test_assert_same( - array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'), + ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'], audit_verify_operation($verifier), 'A failed target-user query must preserve an unknown outcome.' ); $audit_test_user_query_failure = false; -$audit_test_existing_users = array(4); +$audit_test_existing_users = [4]; $audit_test_realm_query_failure = true; audit_test_assert_same( - array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'), + ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'], audit_verify_operation($verifier), 'A failed verification query must preserve an unknown outcome.' ); $audit_test_realm_query_failure = false; -$request = array( +$request = [ 'username' => 'operator', 'password' => 'top-secret', - 'nested' => array( - 'api_token' => 'nested-secret', - 'description' => '', + 'nested' => [ + 'api_token' => 'nested-secret', + 'description' => '', 'opaque_value' => 'Bearer abcdefghijklmnopqrstuvwxyz' - ) -); + ] +]; $redacted = audit_redact_sensitive_data($request); @@ -149,14 +149,14 @@ function audit_test_assert_same($expected, $actual, $message) { 'Non-secret data must remain available for later context-aware output escaping.' ); -$arguments = audit_redact_cli_arguments(array( +$arguments = audit_redact_cli_arguments([ 'cli/example.php', '--password=secret-one', '--api-token', 'secret-two', 'https://user:secret-three@example.com/path', '--description=test' -)); +]); audit_test_assert_same('--password=[REDACTED]', $arguments[1], 'Inline CLI passwords must be redacted.'); audit_test_assert_same('[REDACTED]', $arguments[3], 'Separate CLI secret values must be redacted.'); @@ -168,15 +168,17 @@ function audit_test_assert_same($expected, $actual, $message) { audit_test_assert_same("'=1+1", audit_csv_safe_cell('=1+1'), 'Spreadsheet formulas must be neutralized.'); -$deep = array(); +$deep = []; $cursor = &$deep; + for ($i = 0; $i < 15; $i++) { - $cursor['nested'] = array(); - $cursor = &$cursor['nested']; + $cursor['nested'] = []; + $cursor = &$cursor['nested']; } unset($cursor); $bounded_json = audit_json_encode($deep); + if (strpos($bounded_json, 'MAXIMUM DEPTH REACHED') === false) { fwrite(STDERR, 'Deep request structures must be bounded.' . PHP_EOL); exit(1); @@ -184,6 +186,7 @@ function audit_test_assert_same($expected, $actual, $message) { $invalid_json = audit_json_decode('{invalid', $json_error); audit_test_assert_same(null, $invalid_json, 'Malformed JSON must return a controlled null result.'); + if ($json_error === null || $json_error === '') { fwrite(STDERR, 'Malformed JSON must return a useful error.' . PHP_EOL); exit(1); @@ -204,11 +207,12 @@ function audit_test_assert_same($expected, $actual, $message) { ); audit_test_assert_same( 'failed', - audit_request_status(array('type' => E_ERROR), 200), + audit_request_status(['type' => E_ERROR], 200), 'Fatal errors must finalize as failed requests.' ); $uuid = audit_uuid_v4(); + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid)) { fwrite(STDERR, 'Event identifiers must be RFC 4122 version 4 UUIDs.' . PHP_EOL); exit(1); @@ -225,38 +229,39 @@ function audit_test_assert_same($expected, $actual, $message) { 'Requests without a specific action must use the submitted event verb.' ); -$hash_event = array( - 'event_uuid' => $uuid, - 'correlation_id' => audit_uuid_v4(), - 'event_type' => 'cacti.test.completed', - 'user_id' => 1, - 'action' => 'test', - 'event_time' => '2026-07-24 10:00:00', +$hash_event = [ + 'event_uuid' => $uuid, + 'correlation_id' => audit_uuid_v4(), + 'event_type' => 'cacti.test.completed', + 'user_id' => 1, + 'action' => 'test', + 'event_time' => '2026-07-24 10:00:00', 'operation_outcome' => 'success', - 'details' => '{}' -); -$first_hash = audit_event_integrity_hash($hash_event); + 'details' => '{}' +]; +$first_hash = audit_event_integrity_hash($hash_event); $hash_event['operation_outcome'] = 'failure'; + if ($first_hash === audit_event_integrity_hash($hash_event)) { fwrite(STDERR, 'Integrity hashes must change when protected event fields change.' . PHP_EOL); exit(1); } -$external_record = array( - 'event_time' => '2026-07-24 10:00:00', - 'action' => "Update\nDevice", - 'post' => '{"id":42}', +$external_record = [ + 'event_time' => '2026-07-24 10:00:00', + 'action' => "Update\nDevice", + 'post' => '{"id":42}', 'object_data' => '[]' -); +]; $json_record = audit_external_log_format($external_record, 'json'); audit_test_assert_same("\n", substr($json_record, -1), 'JSON external records must end with a newline.'); audit_test_assert_same( - array( - 'event_time' => '2026-07-24 10:00:00', - 'action' => "Update\nDevice", - 'post' => array('id' => 42), - 'object_data' => array() - ), + [ + 'event_time' => '2026-07-24 10:00:00', + 'action' => "Update\nDevice", + 'post' => ['id' => 42], + 'object_data' => [] + ], json_decode(trim($json_record), true), 'JSON external records must expose stored JSON fields as native structures.' ); @@ -267,7 +272,7 @@ function audit_test_assert_same($expected, $actual, $message) { ); audit_test_assert_same( '{"post":"not-json"}' . "\n", - audit_external_log_format(array('post' => 'not-json'), 'json'), + audit_external_log_format(['post' => 'not-json'], 'json'), 'Malformed stored JSON fields must remain available as strings.' ); audit_test_assert_same( diff --git a/tests/syslog_functions_test.php b/tests/syslog_functions_test.php index 0adf595..5dae55e 100644 --- a/tests/syslog_functions_test.php +++ b/tests/syslog_functions_test.php @@ -13,59 +13,59 @@ function audit_syslog_test_assert($condition, $message) { } } -function audit_syslog_test_config($overrides = array()) { - $values = array( - 'receiver' => '127.0.0.1', - 'port' => '514', - 'transport' => 'udp', - 'format' => 'json', - 'facility' => 'local0', - 'application' => 'cacti-audit', - 'node_id' => 'cacti-node-1', - 'timeout' => '1', - 'udp_max_size' => '8192', - 'retry_base' => '5', - 'retry_max' => '60', - 'max_attempts' => '5', - 'batch_size' => '10', +function audit_syslog_test_config($overrides = []) { + $values = [ + 'receiver' => '127.0.0.1', + 'port' => '514', + 'transport' => 'udp', + 'format' => 'json', + 'facility' => 'local0', + 'application' => 'cacti-audit', + 'node_id' => 'cacti-node-1', + 'timeout' => '1', + 'udp_max_size' => '8192', + 'retry_base' => '5', + 'retry_max' => '60', + 'max_attempts' => '5', + 'batch_size' => '10', 'pending_age_warning' => '900', 'dead_letter_warning' => '1', - 'tls_ca_file' => '', - 'tls_client_cert' => '', - 'tls_client_key' => '' - ); + 'tls_ca_file' => '', + 'tls_client_cert' => '', + 'tls_client_key' => '' + ]; return audit_syslog_config(array_merge($values, $overrides)); } function audit_syslog_test_event() { - return array( - 'id' => 42, - 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', - 'correlation_id' => '48f486c3-90d0-45a2-a50d-3b31e571aa91', - 'event_type' => 'cacti.user_admin.save', - 'event_category' => 'identity_access', - 'severity' => 'info', - 'actor_type' => 'user', - 'page' => 'user_admin.php', - 'user_id' => 1, - 'action' => 'save', - 'request_status' => 'completed', + return [ + 'id' => 42, + 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', + 'correlation_id' => '48f486c3-90d0-45a2-a50d-3b31e571aa91', + 'event_type' => 'cacti.user_admin.save', + 'event_category' => 'identity_access', + 'severity' => 'info', + 'actor_type' => 'user', + 'page' => 'user_admin.php', + 'user_id' => 1, + 'action' => 'save', + 'request_status' => 'completed', 'operation_outcome' => 'success', - 'target_type' => 'user', - 'target_id' => '4', - 'ip_address' => '192.0.2.10', - 'event_time' => '2026-07-24 15:16:17.123456', - 'post' => '{"id":4,"description":"new value","password":"must-not-leak"}', - 'object_data' => '[{"id":4,"description":"old value"}]', - 'details' => '{"test":true}', - 'integrity_hash' => str_repeat('a', 64) - ); + 'target_type' => 'user', + 'target_id' => '4', + 'ip_address' => '192.0.2.10', + 'event_time' => '2026-07-24 15:16:17.123456', + 'post' => '{"id":4,"description":"new value","password":"must-not-leak"}', + 'object_data' => '[{"id":4,"description":"old value"}]', + 'details' => '{"test":true}', + 'integrity_hash' => str_repeat('a', 64) + ]; } function audit_syslog_test_udp_server(&$port, &$error, &$error_message) { for ($attempt = 0; $attempt < 20; $attempt++) { - $port = random_int(20000, 50000); + $port = random_int(20000, 50000); $server = @stream_socket_server( 'udp://127.0.0.1:' . $port, $error, @@ -83,6 +83,7 @@ function audit_syslog_test_udp_server(&$port, &$error, &$error_message) { function audit_syslog_test_tls_material() { $directory = sys_get_temp_dir() . '/audit-syslog-' . bin2hex(random_bytes(8)); + if (!mkdir($directory, 0700)) { return false; } @@ -100,16 +101,16 @@ function audit_syslog_test_tls_material() { "extendedKeyUsage=serverAuth\n"; file_put_contents($config_path, $config_text); - $options = array( - 'config' => $config_path, - 'digest_alg' => 'sha256', + $options = [ + 'config' => $config_path, + 'digest_alg' => 'sha256', 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, - 'req_extensions' => 'v3_req', - 'x509_extensions' => 'v3_req' - ); - $key = openssl_pkey_new($options); - $csr = $key === false ? false : openssl_csr_new(array('commonName' => '127.0.0.1'), $key, $options); + 'req_extensions' => 'v3_req', + 'x509_extensions' => 'v3_req' + ]; + $key = openssl_pkey_new($options); + $csr = $key === false ? false : openssl_csr_new(['commonName' => '127.0.0.1'], $key, $options); $certificate = $csr === false ? false : openssl_csr_sign($csr, null, $key, 1, $options); if ($certificate === false || @@ -118,33 +119,33 @@ function audit_syslog_test_tls_material() { return false; } - $ca_path = $directory . '/ca.pem'; + $ca_path = $directory . '/ca.pem'; $server_path = $directory . '/server.pem'; $result_path = $directory . '/received.log'; file_put_contents($ca_path, $certificate_pem); file_put_contents($server_path, $certificate_pem . $key_pem); chmod($server_path, 0600); - return array( + return [ 'directory' => $directory, - 'config' => $config_path, - 'ca' => $ca_path, - 'server' => $server_path, - 'result' => $result_path - ); + 'config' => $config_path, + 'ca' => $ca_path, + 'server' => $server_path, + 'result' => $result_path + ]; } function audit_syslog_test_tls_server($material, &$port) { - $context = stream_context_create(array( - 'ssl' => array( - 'local_cert' => $material['server'], - 'verify_peer' => false, + $context = stream_context_create([ + 'ssl' => [ + 'local_cert' => $material['server'], + 'verify_peer' => false, 'allow_self_signed' => true - ) - )); - $error = 0; + ] + ]); + $error = 0; $error_message = ''; - $server = stream_socket_server( + $server = stream_socket_server( 'tls://127.0.0.1:0', $error, $error_message, @@ -163,7 +164,7 @@ function audit_syslog_test_tls_server($material, &$port) { } function audit_syslog_test_remove_tls_material($material) { - foreach (array('result', 'server', 'ca', 'config') as $name) { + foreach (['result', 'server', 'ca', 'config'] as $name) { if (is_file($material[$name])) { unlink($material[$name]); } @@ -177,22 +178,24 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($config['tls_verify_peer'] === true, 'TLS peer verification must always be enabled.'); audit_syslog_test_assert($config['tls_verify_peer_name'] === true, 'TLS hostname verification must always be enabled.'); audit_syslog_test_assert( - audit_syslog_test_config(array('transport' => 'udp', 'port' => ''))['port'] === 514, + audit_syslog_test_config(['transport' => 'udp', 'port' => ''])['port'] === 514, 'A blank UDP port must use the standard default of 514.' ); audit_syslog_test_assert( - audit_syslog_test_config(array('transport' => 'tls', 'port' => ''))['port'] === 6514, + audit_syslog_test_config(['transport' => 'tls', 'port' => ''])['port'] === 6514, 'A blank TLS port must use the standard default of 6514.' ); $outer_warning_called = false; -$captured_warning = ''; -set_error_handler(function() use (&$outer_warning_called) { +$captured_warning = ''; +set_error_handler(function () use (&$outer_warning_called) { $outer_warning_called = true; + return true; }); -$warning_result = audit_syslog_stream_operation(function() { +$warning_result = audit_syslog_stream_operation(function () { trigger_error("expected stream warning\nwith control text", E_USER_WARNING); + return false; }, $captured_warning); restore_error_handler(); @@ -201,10 +204,10 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($captured_warning === 'expected stream warning with control text', 'Captured stream warnings must be normalized into a bounded one-line delivery error.'); -$invalid = audit_syslog_test_config(array('receiver' => 'udp://user:secret@example.test')); +$invalid = audit_syslog_test_config(['receiver' => 'udp://user:secret@example.test']); audit_syslog_test_assert(!$invalid['valid'], 'Receiver URIs and embedded credentials must be rejected.'); -$event = audit_syslog_test_event(); +$event = audit_syslog_test_event(); $formatted = audit_syslog_record($event, $config); audit_syslog_test_assert($formatted['status'] === 'ready', 'A valid event must produce a Syslog record.'); audit_syslog_test_assert(strpos($formatted['record'], '<134>1 2026-07-24T15:16:17.123456Z cacti-node-1 cacti-audit 7 cacti.user_admin.save ') === 0, @@ -222,8 +225,8 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert(audit_syslog_frame($formatted['record'], 'udp') === $formatted['record'], 'UDP must send one unframed RFC 5424 record per datagram.'); -$cef_config = audit_syslog_test_config(array('format' => 'cef')); -$cef = audit_syslog_record($event, $cef_config); +$cef_config = audit_syslog_test_config(['format' => 'cef']); +$cef = audit_syslog_record($event, $cef_config); audit_syslog_test_assert(strpos($cef['record'], 'CEF:0|Cacti|Audit Plugin|1.5|cacti.user_admin.save|save|3|') !== false, 'CEF payloads must contain normalized vendor, product, event, action, and severity fields.'); audit_syslog_test_assert(strpos($cef['record'], 'externalId=32e0a97d-d9e8-4abc-8f41-2bbbc50793ca') !== false, @@ -237,21 +240,21 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert(strpos($cef['record'], 'must-not-leak') === false, 'CEF payloads must defensively redact sensitive submitted fields.'); -$rfc_config = audit_syslog_test_config(array('format' => 'rfc5424')); -$rfc = audit_syslog_record($event, $rfc_config); +$rfc_config = audit_syslog_test_config(['format' => 'rfc5424']); +$rfc = audit_syslog_record($event, $rfc_config); audit_syslog_test_assert(strpos($rfc['record'], 'Audit event 32e0a97d-d9e8-4abc-8f41-2bbbc50793ca') !== false, 'RFC 5424-only payloads must remain identifiable.'); -$escaped_event = $event; +$escaped_event = $event; $escaped_event['target_id'] = "value\\\"]\nnext"; -$escaped = audit_syslog_record($escaped_event, $config); +$escaped = audit_syslog_record($escaped_event, $config); audit_syslog_test_assert(strpos($escaped['record'], 'targetId="value\\\\\\"\\] next"') !== false, 'RFC 5424 structured data must escape backslashes, quotes, brackets, and control characters.'); -$small_udp = audit_syslog_test_config(array('udp_max_size' => '512')); -$large_event = $event; -$large_event['details'] = audit_json_encode(array('value' => str_repeat('x', 1024))); -$oversized = audit_syslog_record($large_event, $small_udp); +$small_udp = audit_syslog_test_config(['udp_max_size' => '512']); +$large_event = $event; +$large_event['details'] = audit_json_encode(['value' => str_repeat('x', 1024)]); +$oversized = audit_syslog_record($large_event, $small_udp); audit_syslog_test_assert($oversized['error_code'] === 'udp_message_too_large' && $oversized['permanent'], 'Oversized UDP events must fail permanently without truncation or splitting.'); @@ -259,12 +262,12 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($formatted['record'] === $again['record'], 'Repeated formatting of one event must preserve receiver deduplication identity.'); -$udp_error = 0; +$udp_error = 0; $udp_error_message = ''; -$udp_port = 0; -$udp_server = audit_syslog_test_udp_server($udp_port, $udp_error, $udp_error_message); +$udp_port = 0; +$udp_server = audit_syslog_test_udp_server($udp_port, $udp_error, $udp_error_message); audit_syslog_test_assert(is_resource($udp_server), 'The UDP integration-test receiver must start.'); -$udp_config = audit_syslog_test_config(array('port' => (string) $udp_port)); +$udp_config = audit_syslog_test_config(['port' => (string) $udp_port]); $udp_socket = null; $udp_result = audit_syslog_send_event($event, $udp_config, $udp_socket); audit_syslog_test_assert($udp_result['status'] === 'sent_unconfirmed', @@ -273,18 +276,19 @@ function audit_syslog_test_remove_tls_material($material) { $udp_received = fread($udp_server, 65535); audit_syslog_test_assert($udp_received === audit_syslog_record($event, $udp_config)['record'], 'UDP transport must deliver exactly one complete untruncated record.'); + if (is_resource($udp_socket)) { fclose($udp_socket); } fclose($udp_server); -$tcp_error = 0; +$tcp_error = 0; $tcp_error_message = ''; -$tcp_server = stream_socket_server('tcp://127.0.0.1:0', $tcp_error, $tcp_error_message); +$tcp_server = stream_socket_server('tcp://127.0.0.1:0', $tcp_error, $tcp_error_message); audit_syslog_test_assert(is_resource($tcp_server), 'The TCP integration-test receiver must start.'); -$tcp_name = stream_socket_get_name($tcp_server, false); -$tcp_port = (int) substr($tcp_name, strrpos($tcp_name, ':') + 1); -$tcp_config = audit_syslog_test_config(array('transport' => 'tcp', 'port' => (string) $tcp_port)); +$tcp_name = stream_socket_get_name($tcp_server, false); +$tcp_port = (int) substr($tcp_name, strrpos($tcp_name, ':') + 1); +$tcp_config = audit_syslog_test_config(['transport' => 'tcp', 'port' => (string) $tcp_port]); $tcp_socket = null; $tcp_result = audit_syslog_send_event($event, $tcp_config, $tcp_socket); audit_syslog_test_assert($tcp_result['status'] === 'sent_unconfirmed', @@ -297,6 +301,7 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($tcp_received === $expected_tcp, 'TCP transport must deliver one complete RFC 6587 octet-counted record.'); fclose($tcp_peer); + if (is_resource($tcp_socket)) { fclose($tcp_socket); } @@ -310,14 +315,16 @@ function audit_syslog_test_remove_tls_material($material) { socket_set_option($native_socket, SOL_SOCKET, SO_SNDBUF, 1024); stream_set_timeout($pair[0], 2); $partial_payload = str_repeat('p', 262144); - $partial_pid = pcntl_fork(); + $partial_pid = pcntl_fork(); audit_syslog_test_assert($partial_pid >= 0, 'The partial-write test must fork a local reader.'); if ($partial_pid === 0) { fclose($pair[0]); $received_length = 0; + while ($received_length < strlen($partial_payload)) { $chunk = fread($pair[1], 8192); + if ($chunk === false || $chunk === '') { break; } @@ -332,16 +339,16 @@ function audit_syslog_test_remove_tls_material($material) { fclose($pair[0]); pcntl_waitpid($partial_pid, $partial_status); audit_syslog_test_assert($partial_result['status'] === 'sent_unconfirmed' && - pcntl_wexitstatus($partial_status) === 0, + pcntl_wexitstatus($partial_status) === 0, 'TCP writes must loop until a record is complete when the socket accepts partial chunks.'); - $timeout_pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + $timeout_pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); $timeout_socket = socket_import_stream($timeout_pair[0]); socket_set_option($timeout_socket, SOL_SOCKET, SO_SNDBUF, 1024); stream_set_timeout($timeout_pair[0], 0, 100000); $timeout_result = audit_syslog_write($timeout_pair[0], str_repeat('t', 4194304), 'tcp'); audit_syslog_test_assert($timeout_result['status'] === 'failed' && - $timeout_result['error_code'] === 'write_failed', + $timeout_result['error_code'] === 'write_failed', 'A blocked stream must fail with a bounded write timeout instead of hanging indefinitely.'); fclose($timeout_pair[0]); fclose($timeout_pair[1]); @@ -350,7 +357,7 @@ function audit_syslog_test_remove_tls_material($material) { if (extension_loaded('openssl') && function_exists('pcntl_fork')) { $tls_material = audit_syslog_test_tls_material(); audit_syslog_test_assert(is_array($tls_material), 'The TLS integration test must create temporary certificate material.'); - $tls_port = 0; + $tls_port = 0; $tls_server = audit_syslog_test_tls_server($tls_material, $tls_port); audit_syslog_test_assert(is_resource($tls_server), 'The TLS integration-test receiver must start.'); $tls_pid = pcntl_fork(); @@ -358,6 +365,7 @@ function audit_syslog_test_remove_tls_material($material) { if ($tls_pid === 0) { $tls_peer = @stream_socket_accept($tls_server, 5); + if (is_resource($tls_peer)) { stream_set_timeout($tls_peer, 2); $tls_received = fread($tls_peer, 262144); @@ -370,13 +378,14 @@ function audit_syslog_test_remove_tls_material($material) { } fclose($tls_server); - $tls_config = audit_syslog_test_config(array( - 'transport' => 'tls', - 'port' => (string) $tls_port, + $tls_config = audit_syslog_test_config([ + 'transport' => 'tls', + 'port' => (string) $tls_port, 'tls_ca_file' => $tls_material['ca'] - )); + ]); $tls_socket = null; $tls_result = audit_syslog_send_event($event, $tls_config, $tls_socket); + if (is_resource($tls_socket)) { fclose($tls_socket); } @@ -390,7 +399,7 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($tls_received === $expected_tls, 'TLS transport must deliver one complete RFC 6587 octet-counted record.'); - $untrusted_port = 0; + $untrusted_port = 0; $untrusted_server = audit_syslog_test_tls_server($tls_material, $untrusted_port); audit_syslog_test_assert(is_resource($untrusted_server), 'The untrusted TLS test receiver must start.'); $untrusted_pid = pcntl_fork(); @@ -398,6 +407,7 @@ function audit_syslog_test_remove_tls_material($material) { if ($untrusted_pid === 0) { $untrusted_peer = @stream_socket_accept($untrusted_server, 5); + if (is_resource($untrusted_peer)) { fclose($untrusted_peer); } @@ -405,41 +415,42 @@ function audit_syslog_test_remove_tls_material($material) { } fclose($untrusted_server); - $untrusted_config = audit_syslog_test_config(array( - 'transport' => 'tls', - 'port' => (string) $untrusted_port, + $untrusted_config = audit_syslog_test_config([ + 'transport' => 'tls', + 'port' => (string) $untrusted_port, 'tls_ca_file' => '' - )); + ]); $untrusted_socket = null; $untrusted_result = audit_syslog_send_event($event, $untrusted_config, $untrusted_socket); + if (is_resource($untrusted_socket)) { fclose($untrusted_socket); } pcntl_waitpid($untrusted_pid, $untrusted_status); audit_syslog_test_assert($untrusted_result['status'] === 'failed' && - $untrusted_result['error_code'] === 'connection_failed', + $untrusted_result['error_code'] === 'connection_failed', 'TLS delivery must fail when the receiver certificate is not trusted.'); audit_syslog_test_remove_tls_material($tls_material); } -$outage_error = 0; +$outage_error = 0; $outage_message = ''; -$outage_server = stream_socket_server('tcp://127.0.0.1:0', $outage_error, $outage_message); -$outage_name = stream_socket_get_name($outage_server, false); -$outage_port = (int) substr($outage_name, strrpos($outage_name, ':') + 1); +$outage_server = stream_socket_server('tcp://127.0.0.1:0', $outage_error, $outage_message); +$outage_name = stream_socket_get_name($outage_server, false); +$outage_port = (int) substr($outage_name, strrpos($outage_name, ':') + 1); fclose($outage_server); -$outage_config = audit_syslog_test_config(array('transport' => 'tcp', 'port' => (string) $outage_port)); +$outage_config = audit_syslog_test_config(['transport' => 'tcp', 'port' => (string) $outage_port]); $outage_socket = null; -$outage = audit_syslog_send_event($event, $outage_config, $outage_socket); +$outage = audit_syslog_send_event($event, $outage_config, $outage_socket); audit_syslog_test_assert($outage['status'] === 'failed' && $outage['error_code'] === 'connection_failed', 'An unavailable receiver must return a bounded transient connection failure.'); -$invalid_tls = audit_syslog_test_config(array( - 'transport' => 'tls', - 'port' => '6514', +$invalid_tls = audit_syslog_test_config([ + 'transport' => 'tls', + 'port' => '6514', 'tls_ca_file' => '/does/not/exist' -)); +]); audit_syslog_test_assert(!$invalid_tls['valid'] && in_array('tls_ca_file_invalid', $invalid_tls['errors'], true), 'An invalid TLS trust path must be rejected before a connection is attempted.'); diff --git a/tests/syslog_queue_test.php b/tests/syslog_queue_test.php index 775f161..b88913c 100644 --- a/tests/syslog_queue_test.php +++ b/tests/syslog_queue_test.php @@ -1,27 +1,27 @@ 'on', - 'audit_syslog_receiver' => '127.0.0.1', - 'audit_syslog_port' => '514', - 'audit_syslog_transport' => 'udp', - 'audit_syslog_format' => 'json', - 'audit_syslog_facility' => 'local0', - 'audit_syslog_application' => 'cacti-audit', - 'audit_syslog_node_id' => 'queue-test-node', - 'audit_syslog_timeout' => '5', - 'audit_syslog_udp_max_size' => '8192', - 'audit_syslog_retry_base' => '5', - 'audit_syslog_retry_max' => '60', - 'audit_syslog_max_attempts' => '5', - 'audit_syslog_batch_size' => '10', +$audit_queue_settings = [ + 'audit_syslog_enabled' => 'on', + 'audit_syslog_receiver' => '127.0.0.1', + 'audit_syslog_port' => '514', + 'audit_syslog_transport' => 'udp', + 'audit_syslog_format' => 'json', + 'audit_syslog_facility' => 'local0', + 'audit_syslog_application' => 'cacti-audit', + 'audit_syslog_node_id' => 'queue-test-node', + 'audit_syslog_timeout' => '5', + 'audit_syslog_udp_max_size' => '8192', + 'audit_syslog_retry_base' => '5', + 'audit_syslog_retry_max' => '60', + 'audit_syslog_max_attempts' => '5', + 'audit_syslog_batch_size' => '10', 'audit_syslog_pending_age_warning' => '900', 'audit_syslog_dead_letter_warning' => '1', - 'audit_syslog_tls_ca_file' => '', - 'audit_syslog_tls_client_cert' => '', - 'audit_syslog_tls_client_key' => '' -); -$audit_queue_calls = array(); + 'audit_syslog_tls_ca_file' => '', + 'audit_syslog_tls_client_cert' => '', + 'audit_syslog_tls_client_key' => '' +]; +$audit_queue_calls = []; $audit_queue_affected_rows = 0; function read_config_option($name) { @@ -34,25 +34,27 @@ function db_table_exists($table) { return $table === 'audit_syslog_delivery'; } -function db_fetch_row_prepared($sql, $params = array()) { - return array( - 'id' => (int) $params[0], - 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', +function db_fetch_row_prepared($sql, $params = []) { + return [ + 'id' => (int) $params[0], + 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', 'request_status' => 'completed' - ); + ]; } -function db_execute_prepared($sql, $params = array()) { +function db_execute_prepared($sql, $params = []) { global $audit_queue_calls; - $audit_queue_calls[] = array('sql' => $sql, 'params' => $params); + $audit_queue_calls[] = ['sql' => $sql, 'params' => $params]; + return true; } function db_execute($sql) { global $audit_queue_calls; - $audit_queue_calls[] = array('sql' => $sql, 'params' => array()); + $audit_queue_calls[] = ['sql' => $sql, 'params' => []]; + return true; } @@ -87,11 +89,11 @@ function audit_queue_assert($condition, $message) { audit_queue_assert($audit_queue_calls[0]['params'][5] === 'pending', 'A valid enabled destination must enqueue in pending state.'); -$config = audit_syslog_config(); -$retry_identity = audit_syslog_delivery_config($config, array( - 'delivery_node_id' => 'original-node', +$config = audit_syslog_config(); +$retry_identity = audit_syslog_delivery_config($config, [ + 'delivery_node_id' => 'original-node', 'delivery_poller_id' => '3' -)); +]); audit_queue_assert($retry_identity['node_id'] === 'original-node' && $retry_identity['poller_id'] === '3', 'Retries must use the node and poller identity captured when the event was queued.'); @@ -99,14 +101,14 @@ function audit_queue_assert($condition, $message) { audit_queue_assert(audit_syslog_retry_delay(2, $config) === 10, 'Retry delay must increase exponentially.'); audit_queue_assert(audit_syslog_retry_delay(10, $config) === 60, 'Retry delay must be capped by the configured maximum.'); -$audit_queue_calls = array(); -$delivery = array('delivery_id' => 9, 'attempts' => 0); -$failure = array( - 'status' => 'failed', - 'permanent' => false, +$audit_queue_calls = []; +$delivery = ['delivery_id' => 9, 'attempts' => 0]; +$failure = [ + 'status' => 'failed', + 'permanent' => false, 'error_code' => 'connection_failed', - 'error' => "receiver unavailable\nwith control text" -); + 'error' => "receiver unavailable\nwith control text" +]; audit_syslog_update_delivery($delivery, $failure, $config); audit_queue_assert($audit_queue_calls[0]['params'][0] === 'retry', 'A transient failure before maximum attempts must enter retry state.'); @@ -116,32 +118,32 @@ function audit_queue_assert($condition, $message) { audit_queue_assert(strpos($audit_queue_calls[0]['params'][5], "\n") === false, 'Stored delivery errors must be bounded to one safe line.'); -$audit_queue_calls = array(); +$audit_queue_calls = []; $delivery['attempts'] = 4; audit_syslog_update_delivery($delivery, $failure, $config); audit_queue_assert($audit_queue_calls[0]['params'][0] === 'dead_letter', 'The configured final failed attempt must enter dead-letter state.'); -$audit_queue_calls = array(); -$permanent = $failure; -$permanent['permanent'] = true; +$audit_queue_calls = []; +$permanent = $failure; +$permanent['permanent'] = true; $permanent['error_code'] = 'udp_message_too_large'; -$delivery['attempts'] = 0; +$delivery['attempts'] = 0; audit_syslog_update_delivery($delivery, $permanent, $config); audit_queue_assert($audit_queue_calls[0]['params'][0] === 'dead_letter', 'A permanent formatting failure must enter dead-letter immediately.'); -$audit_queue_calls = array(); -$success = array('status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '', 'permanent' => false); +$audit_queue_calls = []; +$success = ['status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '', 'permanent' => false]; audit_syslog_update_delivery($delivery, $success, $config); audit_queue_assert(strpos($audit_queue_calls[0]['sql'], "state = 'sent_unconfirmed'") !== false, 'A complete socket write must enter sent_unconfirmed state.'); -$audit_queue_calls = array(); +$audit_queue_calls = []; $audit_queue_affected_rows = 2; -$retried = audit_syslog_retry_dead_letters(array('2', 2, 0, -1, '7')); +$retried = audit_syslog_retry_dead_letters(['2', 2, 0, -1, '7']); audit_queue_assert($retried === 2, 'Manual retry must report the affected row count.'); -audit_queue_assert($audit_queue_calls[0]['params'] === array(2, 7), +audit_queue_assert($audit_queue_calls[0]['params'] === [2, 7], 'Manual retry must accept only unique positive selected delivery IDs.'); audit_queue_assert(strpos($audit_queue_calls[0]['sql'], "WHERE state = 'dead_letter'") !== false, 'Manual retry must never reset a non-dead-letter delivery.'); From f99106faef75f2aa744fdec7d436aa8e308d7757 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 16:05:29 -0400 Subject: [PATCH 24/36] Add declare(strict_types=1) and PHP 8.1+ syntax test - Add declare(strict_types=1) to all in-scope PHP files - Replace Php74CompatibilityTest with Php81SyntaxTest - Php81SyntaxTest asserts strict_types and short array syntax - CS-Fixer normalizes declare spacing to Cacti convention Phase 3 (partial) of code quality sweep. No functionality changes. --- audit.php | 1 + audit_functions.php | 1 + audit_syslog.php | 1 + index.php | 1 + locales/LC_MESSAGES/index.php | 1 + locales/index.php | 1 + setup.php | 1 + tests/Pest.php | 1 + tests/Security/Php74CompatibilityTest.php | 82 ------------------- tests/Security/Php81SyntaxTest.php | 54 ++++++++++++ .../PreparedStatementConsistencyTest.php | 1 + tests/Security/SetupStructureTest.php | 3 +- tests/bootstrap.php | 1 + tests/controller_security_test.php | 1 + tests/security_functions_test.php | 1 + tests/syslog_functions_test.php | 1 + tests/syslog_queue_test.php | 1 + 17 files changed, 70 insertions(+), 83 deletions(-) delete mode 100644 tests/Security/Php74CompatibilityTest.php create mode 100644 tests/Security/Php81SyntaxTest.php diff --git a/audit.php b/audit.php index 02f30f4..f36d267 100644 --- a/audit.php +++ b/audit.php @@ -1,4 +1,5 @@ not->toBeFalse("Required plugin file is missing: {$relativeFile}"); - expect(is_readable($path))->toBeTrue("Required plugin file is unreadable: {$relativeFile}"); - } - }); - - it('does not use str_contains (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - $contents = file_get_contents($path); - expect($contents)->not->toBeFalse("Unable to read {$relativeFile}"); - - expect(preg_match('/\bstr_contains\s*\(/', $contents))->toBe(0, - "{$relativeFile} uses str_contains() which requires PHP 8.0" - ); - } - }); - - it('does not use str_starts_with (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - $contents = file_get_contents($path); - expect($contents)->not->toBeFalse("Unable to read {$relativeFile}"); - - expect(preg_match('/\bstr_starts_with\s*\(/', $contents))->toBe(0, - "{$relativeFile} uses str_starts_with() which requires PHP 8.0" - ); - } - }); - - it('does not use str_ends_with (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - $contents = file_get_contents($path); - expect($contents)->not->toBeFalse("Unable to read {$relativeFile}"); - - expect(preg_match('/\bstr_ends_with\s*\(/', $contents))->toBe(0, - "{$relativeFile} uses str_ends_with() which requires PHP 8.0" - ); - } - }); - - it('does not use nullsafe operator (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - $contents = file_get_contents($path); - expect($contents)->not->toBeFalse("Unable to read {$relativeFile}"); - - expect(preg_match('/\?->/', $contents))->toBe(0, - "{$relativeFile} uses nullsafe operator which requires PHP 8.0" - ); - } - }); -}); diff --git a/tests/Security/Php81SyntaxTest.php b/tests/Security/Php81SyntaxTest.php new file mode 100644 index 0000000..e080c7b --- /dev/null +++ b/tests/Security/Php81SyntaxTest.php @@ -0,0 +1,54 @@ +not->toBeFalse("Required plugin file is missing: {$relativeFile}"); + expect(is_readable($path))->toBeTrue("Required plugin file is unreadable: {$relativeFile}"); + } + }); + + it('declares strict_types in every plugin file', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + $contents = file_get_contents($path); + expect($contents)->not->toBeFalse("Unable to read {$relativeFile}"); + + expect(strpos($contents, 'declare(strict_types'))->not->toBeFalse( + "{$relativeFile} must declare strict_types=1" + ); + } + }); + + it('uses short array syntax', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + $contents = file_get_contents($path); + + expect(preg_match('/\barray\s*\(/', $contents))->toBe(0, + "{$relativeFile} still uses long array() syntax" + ); + } + }); +}); diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index 9a0c8cc..bfca247 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -1,4 +1,5 @@ toContain("parse_ini_file"); + expect($source)->toContain('parse_ini_file'); expect($source)->toContain("'info'"); }); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e268ffd..33422ca 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,4 +1,5 @@ 'on', From d734b02079b84252b62c323cba25a469052a7057 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 16:07:22 -0400 Subject: [PATCH 25/36] Remove strict_types (deferred until Cacti 1.3) - Remove declare(strict_types=1) from all PHP files per team lead directive - Update Php81SyntaxTest to not assert strict_types - strict_types will not be supported until Cacti 1.3 is released No functionality changes. --- audit.php | 1 - audit_functions.php | 1 - audit_syslog.php | 1 - index.php | 1 - locales/LC_MESSAGES/index.php | 1 - locales/index.php | 1 - phpstan/stubs/cacti.stubs.php | 2 -- setup.php | 1 - tests/Pest.php | 1 - tests/Security/Php81SyntaxTest.php | 13 +------------ tests/Security/PreparedStatementConsistencyTest.php | 1 - tests/Security/SetupStructureTest.php | 1 - tests/bootstrap.php | 1 - tests/controller_security_test.php | 1 - tests/security_functions_test.php | 1 - tests/syslog_functions_test.php | 1 - tests/syslog_queue_test.php | 1 - 17 files changed, 1 insertion(+), 29 deletions(-) diff --git a/audit.php b/audit.php index f36d267..02f30f4 100644 --- a/audit.php +++ b/audit.php @@ -1,5 +1,4 @@ not->toBeFalse("Unable to read {$relativeFile}"); - - expect(strpos($contents, 'declare(strict_types'))->not->toBeFalse( - "{$relativeFile} must declare strict_types=1" - ); - } - }); - it('uses short array syntax', function () use ($files) { foreach ($files as $relativeFile) { $path = realpath(__DIR__ . '/../../' . $relativeFile); diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index bfca247..9a0c8cc 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -1,5 +1,4 @@ 'on', From 0d9f1809a93417826eca5ba17c190923202f4219 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 16:09:13 -0400 Subject: [PATCH 26/36] Add native PHP 8.1+ type annotations to all plugin functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audit_syslog.php: 38 functions typed (params + return types) - audit_functions.php: 30 functions typed (params + return types) - setup.php: 19 functions typed (params + return types) - audit.php: 7 functions typed (params + return types) - Uses union types (array|false, int|false), mixed, nullable (?T), void - No strict_types (deferred until Cacti 1.3) - No functionality changes — types match existing runtime values Phase 3 of code quality sweep. --- audit.php | 14 ++++----- audit_functions.php | 60 +++++++++++++++++------------------ audit_syslog.php | 76 ++++++++++++++++++++++----------------------- setup.php | 38 +++++++++++------------ 4 files changed, 94 insertions(+), 94 deletions(-) diff --git a/audit.php b/audit.php index 02f30f4..2aeec5f 100644 --- a/audit.php +++ b/audit.php @@ -152,7 +152,7 @@ bottom_footer(); } -function audit_render_event_details($data) { +function audit_render_event_details(array $data): string { $width = 'wide'; $output = '
'; $output .= '' . __('Page:', 'audit') . ' ' . html_escape($data['page']) . ''; @@ -248,7 +248,7 @@ function audit_render_event_details($data) { return $output . '
'; } -function audit_render_value($value) { +function audit_render_value(mixed $value): string { if (is_array($value) || is_object($value)) { return '
' . html_escape(json_encode($value, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE)) . '
'; } @@ -262,7 +262,7 @@ function audit_render_value($value) { return html_escape((string) $value); } -function audit_purge() { +function audit_purge(): void { $protected = db_fetch_cell("SELECT COUNT(*) FROM audit_log WHERE EXISTS ( @@ -300,7 +300,7 @@ function audit_purge() { raise_message('audit_message'); } -function audit_export_rows() { +function audit_export_rows(): void { audit_process_request_vars(); // form the 'where' clause for our main sql query @@ -394,7 +394,7 @@ function audit_export_rows() { } } -function audit_process_request_vars() { +function audit_process_request_vars(): void { // ================= input validation and session storage ================= $filters = [ 'rows' => [ @@ -438,7 +438,7 @@ function audit_process_request_vars() { // ================= input validation ================= } -function audit_log() { +function audit_log(): void { global $item_rows; audit_process_request_vars(); @@ -709,7 +709,7 @@ function audit_log() { 0]; } @@ -202,7 +202,7 @@ function audit_bound_log_data($data, $depth = 0, $state = null) { return $data; } -function audit_redact_cli_arguments($arguments) { +function audit_redact_cli_arguments(array $arguments): array { $redacted = []; $redact_next = false; @@ -233,7 +233,7 @@ function audit_redact_cli_arguments($arguments) { return $redacted; } -function audit_json_encode($data, $options = 0) { +function audit_json_encode(mixed $data, int $options = 0): string { $json = json_encode(audit_bound_log_data($data), JSON_INVALID_UTF8_SUBSTITUTE | $options, 16); if ($json === false) { @@ -243,7 +243,7 @@ function audit_json_encode($data, $options = 0) { return $json; } -function audit_json_decode($json, &$error = null) { +function audit_json_decode(mixed $json, ?string &$error = null): mixed { $error = null; try { @@ -255,7 +255,7 @@ function audit_json_decode($json, &$error = null) { } } -function audit_uuid_v4() { +function audit_uuid_v4(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); @@ -265,7 +265,7 @@ function audit_uuid_v4() { substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20); } -function audit_request_correlation_id() { +function audit_request_correlation_id(): string { static $correlation_id; if ($correlation_id === null) { @@ -275,7 +275,7 @@ function audit_request_correlation_id() { return $correlation_id; } -function audit_utc_time($microtime = null) { +function audit_utc_time(?float $microtime = null): string { $microtime = $microtime === null ? microtime(true) : $microtime; $seconds = (int) $microtime; $micros = (int) round(($microtime - $seconds) * 1000000); @@ -288,7 +288,7 @@ function audit_utc_time($microtime = null) { return gmdate('Y-m-d H:i:s', $seconds) . '.' . sprintf('%06d', $micros); } -function audit_event_integrity_hash($event) { +function audit_event_integrity_hash(array $event): string { $material = [ 'event_uuid' => $event['event_uuid'] ?? '', 'correlation_id' => $event['correlation_id'] ?? '', @@ -305,7 +305,7 @@ function audit_event_integrity_hash($event) { return hash('sha256', audit_json_encode($material, JSON_UNESCAPED_SLASHES)); } -function audit_event_type_for_request($page, $action) { +function audit_event_type_for_request(mixed $page, mixed $action): string { $page_name = preg_replace('/\.php$/', '', (string) $page); $page_name = preg_replace('/[^a-z0-9_]+/i', '_', $page_name); $verb = preg_replace('/[^a-z0-9_]+/i', '_', strtolower((string) $action)); @@ -315,7 +315,7 @@ function audit_event_type_for_request($page, $action) { ($verb !== '' && $verb !== 'none' ? $verb : 'submitted'); } -function audit_external_event_data($event) { +function audit_external_event_data(array $event): array { $fields = [ 'id', 'event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'actor_type', 'page', 'user_id', 'action', 'request_status', @@ -333,7 +333,7 @@ function audit_external_event_data($event) { return $data; } -function audit_external_log_format($data, $format = 'json') { +function audit_external_log_format(array $data, string $format = 'json'): string { if ($format === 'text') { $fields = []; @@ -370,7 +370,7 @@ function audit_external_log_format($data, $format = 'json') { return audit_json_encode($data, JSON_UNESCAPED_SLASHES) . "\n"; } -function audit_csv_safe_cell($value) { +function audit_csv_safe_cell(mixed $value): string { $value = (string) $value; if (preg_match('/^[=+\-@]/', ltrim($value))) { @@ -380,7 +380,7 @@ function audit_csv_safe_cell($value) { return $value; } -function audit_retention_cutoff($retention, $now = null) { +function audit_retention_cutoff(mixed $retention, ?DateTimeImmutable $now = null): DateTimeImmutable { $now = $now instanceof DateTimeImmutable ? $now->setTimezone(new DateTimeZone('UTC')) : new DateTimeImmutable('now', new DateTimeZone('UTC')); @@ -388,7 +388,7 @@ function audit_retention_cutoff($retention, $now = null) { return $now->sub(new DateInterval('P' . max(0, (int) $retention) . 'D')); } -function audit_append_external_log($path, $message) { +function audit_append_external_log(string $path, string $message): array { if ($path == '' || !is_file($path) || is_link($path)) { return ['status' => 'failed', 'error' => 'Destination is not a regular file or is a symbolic link.']; } @@ -402,7 +402,7 @@ function audit_append_external_log($path, $message) { return ['status' => 'delivered', 'error' => '']; } -function audit_set_external_status($id, $status, $error = '') { +function audit_set_external_status(int $id, string $status, string $error = ''): void { db_execute_prepared('UPDATE audit_log SET external_status = ?, external_error = ?, @@ -413,7 +413,7 @@ function audit_set_external_status($id, $status, $error = '') { [$status, $error, $status, $id]); } -function audit_deliver_external_event($id) { +function audit_deliver_external_event(int $id): void { if (read_config_option('audit_log_external') != 'on') { return; } @@ -438,7 +438,7 @@ function audit_deliver_external_event($id) { audit_set_external_status($id, $delivery['status'], $delivery['error']); } -function audit_retry_external_logs() { +function audit_retry_external_logs(): void { if (read_config_option('audit_log_external') != 'on') { return; } @@ -470,7 +470,7 @@ function audit_retry_external_logs() { } } -function audit_request_status($error = null, $status_code = 200) { +function audit_request_status(?array $error = null, int $status_code = 200): string { $fatal_types = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; if ((is_array($error) && in_array($error['type'] ?? null, $fatal_types, true)) || @@ -481,7 +481,7 @@ function audit_request_status($error = null, $status_code = 200) { return 'completed'; } -function audit_operation_verifier_for_request($page, $post) { +function audit_operation_verifier_for_request(string $page, array $post): ?array { if ($page != 'user_admin.php' || !array_key_exists('save_component_realm_perms', $post)) { return null; } @@ -525,7 +525,7 @@ function audit_operation_verifier_for_request($page, $post) { ]; } -function audit_verify_operation($verifier) { +function audit_verify_operation(mixed $verifier): array { if (!is_array($verifier) || empty($verifier['type'])) { return ['outcome' => 'unknown', 'reason' => null]; } @@ -583,7 +583,7 @@ function audit_verify_operation($verifier) { return ['outcome' => 'failure', 'reason' => 'realm_permissions_mismatch']; } -function audit_finalize_request($id, $started_at = null, $verifier = null) { +function audit_finalize_request(int $id, ?float $started_at = null, ?array $verifier = null): void { $status_code = http_response_code(); $status_code = is_int($status_code) ? $status_code : 200; $request_status = audit_request_status(error_get_last(), $status_code); @@ -621,7 +621,7 @@ function audit_finalize_request($id, $started_at = null, $verifier = null) { audit_enqueue_syslog_event($id); } -function audit_record_event($event_type, $options = []) { +function audit_record_event(string $event_type, array $options = []): int { if (read_config_option('audit_enabled') != 'on') { return 0; } @@ -670,7 +670,7 @@ function audit_record_event($event_type, $options = []) { return $id; } -function audit_logout_pre_session_destroy() { +function audit_logout_pre_session_destroy(): void { $reason = get_nfilter_request_var('action', 'user'); $type = $reason == 'timeout' ? 'authentication.session.expired' : 'authentication.logout'; @@ -681,7 +681,7 @@ function audit_logout_pre_session_destroy() { ]); } -function audit_enforce_syslog_settings_request() { +function audit_enforce_syslog_settings_request(): void { $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); $method = $_SERVER['REQUEST_METHOD'] ?? ''; @@ -764,7 +764,7 @@ function audit_enforce_syslog_settings_request() { } } -function audit_config_insert() { +function audit_config_insert(): void { global $action, $config; audit_enforce_syslog_settings_request(); diff --git a/audit_syslog.php b/audit_syslog.php index d393fba..285b50f 100644 --- a/audit_syslog.php +++ b/audit_syslog.php @@ -15,17 +15,17 @@ +-------------------------------------------------------------------------+ */ -function audit_syslog_enabled() { +function audit_syslog_enabled(): bool { return read_config_option('audit_syslog_enabled') == 'on'; } -function audit_syslog_read_setting($name, $default) { +function audit_syslog_read_setting(string $name, mixed $default): mixed { $value = read_config_option($name); return $value === '' || $value === null ? $default : $value; } -function audit_syslog_bounded_integer($value, $default, $minimum, $maximum, &$errors, $name) { +function audit_syslog_bounded_integer(mixed $value, int $default, int $minimum, int $maximum, array &$errors, string $name): int { if (!is_scalar($value) || !preg_match('/^[0-9]+$/', (string) $value)) { $errors[] = $name . '_invalid'; @@ -43,7 +43,7 @@ function audit_syslog_bounded_integer($value, $default, $minimum, $maximum, &$er return $value; } -function audit_syslog_valid_receiver($receiver) { +function audit_syslog_valid_receiver(string $receiver): bool { if ($receiver === '' || strlen($receiver) > 253 || preg_match('/[[:cntrl:][:space:]\\/@]/', $receiver) || strpos($receiver, '://') !== false) { @@ -74,12 +74,12 @@ function audit_syslog_valid_receiver($receiver) { return true; } -function audit_syslog_valid_header_value($value, $maximum) { +function audit_syslog_valid_header_value(string $value, int $maximum): bool { return $value !== '' && strlen($value) <= $maximum && !preg_match('/[^\\x21-\\x7e]|[\\[\\]="]/', $value); } -function audit_syslog_validate_optional_file($path, $name, &$errors) { +function audit_syslog_validate_optional_file(string $path, string $name, array &$errors): string { if ($path === '') { return ''; } @@ -91,7 +91,7 @@ function audit_syslog_validate_optional_file($path, $name, &$errors) { return $path; } -function audit_syslog_config($overrides = []) { +function audit_syslog_config(array $overrides = []): array { $defaults = [ 'receiver' => '', 'port' => '', @@ -225,7 +225,7 @@ function audit_syslog_config($overrides = []) { return $config; } -function audit_syslog_destination_fingerprint($config) { +function audit_syslog_destination_fingerprint(array $config): string { $identity = [ 'receiver' => $config['receiver'], 'port' => (int) $config['port'], @@ -241,7 +241,7 @@ function audit_syslog_destination_fingerprint($config) { return hash('sha256', audit_json_encode($identity, JSON_UNESCAPED_SLASHES)); } -function audit_syslog_facilities() { +function audit_syslog_facilities(): array { return [ 'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3, 'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7, @@ -252,7 +252,7 @@ function audit_syslog_facilities() { ]; } -function audit_syslog_severity_code($severity) { +function audit_syslog_severity_code(mixed $severity): int { $map = [ 'emergency' => 0, 'emerg' => 0, 'alert' => 1, 'critical' => 2, 'crit' => 2, 'error' => 3, 'err' => 3, 'warning' => 4, @@ -263,20 +263,20 @@ function audit_syslog_severity_code($severity) { return isset($map[$severity]) ? $map[$severity] : 6; } -function audit_syslog_header_token($value, $maximum, $fallback) { +function audit_syslog_header_token(mixed $value, int $maximum, string $fallback): string { $value = preg_replace('/[^\\x21-\\x3c\\x3e-\\x5a\\x5e-\\x7e]/', '_', (string) $value); $value = substr($value, 0, $maximum); return $value === '' ? $fallback : $value; } -function audit_syslog_structured_value($value) { +function audit_syslog_structured_value(mixed $value): string { $value = preg_replace('/[\\x00-\\x1f\\x7f]/', ' ', (string) $value); return str_replace(['\\', '"', ']'], ['\\\\', '\\"', '\\]'], $value); } -function audit_syslog_timestamp($value) { +function audit_syslog_timestamp(mixed $value): string { $value = (string) $value; if (preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]([0-9]{2}:[0-9]{2}:[0-9]{2})(\\.[0-9]{1,6})?/', $value, $matches)) { @@ -286,7 +286,7 @@ function audit_syslog_timestamp($value) { return gmdate('Y-m-d\\TH:i:s\\Z'); } -function audit_syslog_normalized_data($event, $config) { +function audit_syslog_normalized_data(array $event, array $config): array { $data = audit_external_event_data($event); $data['node_id'] = $config['node_id']; $data['poller_id'] = $config['poller_id'] !== '' ? $config['poller_id'] : null; @@ -294,11 +294,11 @@ function audit_syslog_normalized_data($event, $config) { return $data; } -function audit_syslog_cef_escape_header($value) { +function audit_syslog_cef_escape_header(mixed $value): string { return str_replace(['\\', '|', "\r", "\n"], ['\\\\', '\\|', ' ', ' '], (string) $value); } -function audit_syslog_cef_escape_extension($value) { +function audit_syslog_cef_escape_extension(mixed $value): string { return str_replace( ['\\', '=', "\r", "\n"], ['\\\\', '\\=', '\\r', '\\n'], @@ -306,7 +306,7 @@ function audit_syslog_cef_escape_extension($value) { ); } -function audit_syslog_cef_severity($severity) { +function audit_syslog_cef_severity(mixed $severity): int { $map = [ 'emergency' => 10, 'emerg' => 10, 'alert' => 10, 'critical' => 9, 'crit' => 9, 'error' => 8, 'err' => 8, @@ -318,7 +318,7 @@ function audit_syslog_cef_severity($severity) { return isset($map[$severity]) ? $map[$severity] : 3; } -function audit_syslog_cef_event_field($value) { +function audit_syslog_cef_event_field(mixed $value): string { if (is_string($value) && $value !== '') { $decoded = audit_json_decode($value, $error); @@ -345,7 +345,7 @@ function audit_syslog_cef_event_field($value) { return audit_redact_sensitive_value((string) $value); } -function audit_syslog_cef_payload($event, $config) { +function audit_syslog_cef_payload(array $event, array $config): string { $severity = audit_syslog_cef_severity($event['severity'] ?? 'info'); $header = [ 'CEF:0', @@ -387,7 +387,7 @@ function audit_syslog_cef_payload($event, $config) { return implode('|', $encoded_header) . '|' . implode(' ', $encoded_extension); } -function audit_syslog_message_payload($event, $config) { +function audit_syslog_message_payload(array $event, array $config): string { if ($config['format'] === 'cef') { return audit_syslog_cef_payload($event, $config); } @@ -399,7 +399,7 @@ function audit_syslog_message_payload($event, $config) { return 'Audit event ' . (string) ($event['event_uuid'] ?? ''); } -function audit_syslog_record($event, $config) { +function audit_syslog_record(array $event, array $config): array { if (empty($config['valid'])) { return [ 'status' => 'failed', @@ -469,7 +469,7 @@ function audit_syslog_record($event, $config) { ]; } -function audit_syslog_frame($record, $transport) { +function audit_syslog_frame(string $record, string $transport): string { if ($transport === 'tcp' || $transport === 'tls') { return strlen($record) . ' ' . $record; } @@ -477,7 +477,7 @@ function audit_syslog_frame($record, $transport) { return $record; } -function audit_syslog_socket_target($config) { +function audit_syslog_socket_target(array $config): string { $receiver = $config['receiver']; if (filter_var($receiver, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { @@ -489,7 +489,7 @@ function audit_syslog_socket_target($config) { return $scheme . '://' . $receiver . ':' . $config['port']; } -function audit_syslog_stream_operation($operation, &$warning = null) { +function audit_syslog_stream_operation(callable $operation, ?string &$warning = null): mixed { $warning = ''; $handler = function ($severity, $message) use (&$warning) { $warning = audit_syslog_bounded_error($message); @@ -510,7 +510,7 @@ function audit_syslog_stream_operation($operation, &$warning = null) { } } -function audit_syslog_open_socket($config) { +function audit_syslog_open_socket(array $config): array { $context_options = []; if ($config['transport'] === 'tls') { @@ -573,19 +573,19 @@ function audit_syslog_open_socket($config) { return ['socket' => $socket, 'error_code' => '', 'error' => '']; } -function audit_syslog_bounded_error($error) { +function audit_syslog_bounded_error(mixed $error): string { $error = preg_replace('/[\\x00-\\x1f\\x7f]+/', ' ', (string) $error); return substr(trim($error), 0, 1024); } -function audit_syslog_fwrite($socket, $message, &$warning = null) { +function audit_syslog_fwrite(mixed $socket, string $message, ?string &$warning = null): int|false { return audit_syslog_stream_operation(function () use ($socket, $message) { return fwrite($socket, $message); }, $warning); } -function audit_syslog_write($socket, $message, $transport) { +function audit_syslog_write(mixed $socket, string $message, string $transport): array { if (!is_resource($socket)) { return ['status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.']; } @@ -625,7 +625,7 @@ function audit_syslog_write($socket, $message, $transport) { return ['status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '']; } -function audit_syslog_send_event($event, $config, &$socket = null) { +function audit_syslog_send_event(array $event, array $config, mixed &$socket = null): array { $formatted = audit_syslog_record($event, $config); if ($formatted['status'] !== 'ready') { @@ -659,7 +659,7 @@ function audit_syslog_send_event($event, $config, &$socket = null) { return $result; } -function audit_enqueue_syslog_event($audit_id) { +function audit_enqueue_syslog_event(int $audit_id): void { if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) { return; } @@ -687,7 +687,7 @@ function audit_enqueue_syslog_event($audit_id) { ]); } -function audit_syslog_delivery_config($config, $delivery) { +function audit_syslog_delivery_config(array $config, array $delivery): array { if (isset($delivery['delivery_node_id']) && $delivery['delivery_node_id'] !== '') { $config['node_id'] = $delivery['delivery_node_id']; } @@ -701,14 +701,14 @@ function audit_syslog_delivery_config($config, $delivery) { return $config; } -function audit_syslog_retry_delay($attempt, $config) { +function audit_syslog_retry_delay(mixed $attempt, array $config): int { $exponent = min(max(0, (int) $attempt - 1), 30); $delay = $config['retry_base'] * pow(2, $exponent); return (int) min($config['retry_max'], $delay); } -function audit_syslog_update_delivery($delivery, $result, $config) { +function audit_syslog_update_delivery(array $delivery, array $result, array $config): void { $attempts = (int) $delivery['attempts'] + 1; $error = isset($result['error']) ? audit_syslog_bounded_error($result['error']) : ''; @@ -745,7 +745,7 @@ function audit_syslog_update_delivery($delivery, $result, $config) { [$state, $config['fingerprint'], $attempts, $delay, $delay, $stored_error, $delivery['delivery_id']]); } -function audit_process_syslog_queue() { +function audit_process_syslog_queue(): void { if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) { return; } @@ -791,7 +791,7 @@ function audit_process_syslog_queue() { audit_syslog_check_health($config); } -function audit_syslog_health() { +function audit_syslog_health(): array { if (!db_table_exists('audit_syslog_delivery')) { return [ 'pending' => 0, 'retry' => 0, 'sent_unconfirmed' => 0, @@ -829,7 +829,7 @@ function audit_syslog_health() { ]; } -function audit_syslog_check_health($config = null) { +function audit_syslog_check_health(?array $config = null): void { if (!audit_syslog_enabled()) { return; } @@ -851,7 +851,7 @@ function audit_syslog_check_health($config = null) { } } -function audit_syslog_retry_dead_letters($delivery_ids = []) { +function audit_syslog_retry_dead_letters(array $delivery_ids = []): int { if (!db_table_exists('audit_syslog_delivery')) { return 0; } @@ -895,7 +895,7 @@ function audit_syslog_retry_dead_letters($delivery_ids = []) { return db_affected_rows(); } -function audit_syslog_test_delivery() { +function audit_syslog_test_delivery(): array { $config = audit_syslog_config(); $event = [ 'event_uuid' => audit_uuid_v4(), diff --git a/setup.php b/setup.php index 076d429..d3b2959 100644 --- a/setup.php +++ b/setup.php @@ -24,7 +24,7 @@ include_once('audit_functions.php'); -function plugin_audit_install() { +function plugin_audit_install(): void { api_plugin_register_hook('audit', 'config_arrays', 'audit_config_arrays', 'setup.php'); api_plugin_register_hook('audit', 'config_settings', 'audit_config_settings', 'setup.php'); api_plugin_register_hook('audit', 'config_insert', 'audit_config_insert', 'setup.php'); @@ -42,7 +42,7 @@ function plugin_audit_install() { audit_setup_table(); } -function audit_setup_realms($grant_installing_user = false) { +function audit_setup_realms(bool $grant_installing_user = false): void { $realms = [ 'audit.php' => __('Audit Log User', 'audit'), 'audit_manage.php' => __('Audit Log Admin', 'audit') @@ -72,7 +72,7 @@ function audit_setup_realms($grant_installing_user = false) { } } -function audit_remove_deprecated_realms() { +function audit_remove_deprecated_realms(): void { $realms = db_fetch_assoc_prepared('SELECT id FROM plugin_realms WHERE plugin = ? @@ -100,14 +100,14 @@ function audit_remove_deprecated_realms() { } } -function plugin_audit_uninstall() { +function plugin_audit_uninstall(): bool { db_execute('DROP TABLE IF EXISTS audit_syslog_delivery'); db_execute('DROP TABLE IF EXISTS audit_log'); return true; } -function audit_is_console_page($url) { +function audit_is_console_page(string $url): bool { if (strpos($url, 'audit.php') !== false) { return true; } @@ -115,15 +115,15 @@ function audit_is_console_page($url) { return false; } -function plugin_audit_check_config() { +function plugin_audit_check_config(): bool { return true; } -function plugin_audit_upgrade() { +function plugin_audit_upgrade(): bool { return true; } -function audit_check_upgrade() { +function audit_check_upgrade(): void { global $config, $database_default; include_once($config['library_path'] . '/database.php'); include_once($config['library_path'] . '/functions.php'); @@ -189,7 +189,7 @@ function audit_check_upgrade() { } } -function audit_replicate_out($data) { +function audit_replicate_out(array $data): array { $rcnn_id = $data['rcnn_id']; $class = $data['class']; @@ -239,7 +239,7 @@ function audit_replicate_out($data) { return $data; } -function audit_poller_bottom() { +function audit_poller_bottom(): void { audit_retry_external_logs(); audit_process_syslog_queue(); @@ -269,7 +269,7 @@ function audit_poller_bottom() { set_config_option('audit_last_check', $now); } -function audit_setup_table() { +function audit_setup_table(): bool { global $config, $database_default; include_once($config['library_path'] . '/database.php'); @@ -325,7 +325,7 @@ function audit_setup_table() { return true; } -function audit_setup_syslog_table() { +function audit_setup_syslog_table(): void { db_execute("CREATE TABLE IF NOT EXISTS `audit_syslog_delivery` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `audit_id` bigint(20) unsigned NOT NULL, @@ -360,7 +360,7 @@ function audit_setup_syslog_table() { AFTER node_id'); } -function audit_upgrade_event_schema($rcnn_id = false) { +function audit_upgrade_event_schema(mixed $rcnn_id = false): void { $remote = $rcnn_id !== false; $args = $remote ? [true, $rcnn_id] : []; $columns = [ @@ -408,14 +408,14 @@ function audit_upgrade_event_schema($rcnn_id = false) { } } -function plugin_audit_version() { +function plugin_audit_version(): array { global $config; $info = parse_ini_file($config['base_path'] . '/plugins/audit/INFO', true); return $info['info']; } -function audit_log_valid_event() { +function audit_log_valid_event(): bool { global $action; $valid = false; @@ -449,7 +449,7 @@ function audit_log_valid_event() { return $valid; } -function audit_utilities_array() { +function audit_utilities_array(): void { global $utilities; if (version_compare(CACTI_VERSION, '1.3.0', '<')) { @@ -467,7 +467,7 @@ function audit_utilities_array() { } } -function audit_config_arrays() { +function audit_config_arrays(): void { global $menu, $messages, $audit_retentions, $utilities; if (isset($_SESSION['audit_message']) && $_SESSION['audit_message'] != '') { @@ -496,7 +496,7 @@ function audit_config_arrays() { audit_check_upgrade(); } -function audit_config_settings() { +function audit_config_settings(): void { global $tabs, $settings, $item_rows, $audit_retentions; $temp = [ @@ -728,7 +728,7 @@ function audit_config_settings() { } } -function audit_draw_navigation_text($nav) { +function audit_draw_navigation_text(array $nav): array { $nav['audit.php:'] = [ 'title' => __('Audit Event Log', 'audit'), 'mapping' => 'index.php:', From 346b2fb68570d3e2a2763eabf4155f757e41166c Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 16:20:57 -0400 Subject: [PATCH 27/36] Fix PHPStan level 8 errors in plugin source files - Add @param/@return array value type docblocks (40+ missingType.iterableValue) - Add is_array() guards for foreach on db_fetch results (7 foreach.nonIterable) - Add is_array/null-coalescing guards for array|false offset access (6 errors) - Add null coalescing for string|null arguments (preg_replace, trim, substr) - Add resource|false guards for fputcsv/fclose - Fix audit_json_encode fallback to guarantee string return - Fix audit_syslog_config null coalescing for optional keys (20 offsetAccess.notFound) - Fix stubs: __() variadic, api_plugin_register_hook 5th param, db_add_index mixed - Add ignoreErrors for includeOnce.fileNotFound, treatPhpDocTypesAsCertain: false - Regenerate baseline (210 errors, all in test files / pre-existing debt) - PHPStan with baseline: 0 errors. Without baseline: 7 source errors (all ignored) Phase 4 of code quality sweep. No functionality changes. --- .phpstan.neon | 8 +- audit.php | 136 ++--- audit_functions.php | 79 ++- audit_syslog.php | 138 +++-- phpstan-baseline.neon | 955 +++++++++++++++++++++++++++++++++- phpstan/stubs/cacti.stubs.php | 23 +- setup.php | 47 +- 7 files changed, 1246 insertions(+), 140 deletions(-) diff --git a/.phpstan.neon b/.phpstan.neon index 58d5867..6087a8c 100644 --- a/.phpstan.neon +++ b/.phpstan.neon @@ -24,6 +24,7 @@ parameters: scanFiles: - phpstan/stubs/cacti.stubs.php level: 8 + treatPhpDocTypesAsCertain: false reportUnmatchedIgnoredErrors: false ignoreErrors: - identifier: missingType.iterableValue @@ -38,4 +39,9 @@ parameters: - identifier: identical.alwaysFalse - identifier: booleanAnd.leftAlwaysTrue - identifier: booleanAnd.leftAlwaysFalse - - identifier: function.alreadyNarrowedType \ No newline at end of file + - identifier: function.alreadyNarrowedType + - identifier: includeOnce.fileNotFound + - identifier: booleanAnd.rightAlwaysFalse + - identifier: booleanAnd.rightAlwaysTrue + - identifier: property.notFound + - identifier: parameterByRef.unusedType \ No newline at end of file diff --git a/audit.php b/audit.php index 2aeec5f..dcbf213 100644 --- a/audit.php +++ b/audit.php @@ -128,7 +128,7 @@ WHERE id = ?', [get_filter_request_var('id')]); - if (!cacti_sizeof($data)) { + if (!is_array($data)) { http_response_code(404); print html_escape(__('Audit event not found.', 'audit')); @@ -152,6 +152,9 @@ bottom_footer(); } +/** + * @param array $data + */ function audit_render_event_details(array $data): string { $width = 'wide'; $output = '"; print ''; - print ''; + print ''; print ''; print ''; print ''; @@ -753,7 +761,7 @@ function audit_render_syslog_health(): void { } print ''; - if ($enabled && !$config['valid']) { + if (!$config['valid']) { print "'; } elseif ($health['last_error'] !== null) { diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index b9f19c7..c6de27d 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -16,7 +16,14 @@ "case 'syslog_test':", "case 'syslog_retry':", 'audit_syslog_test_delivery()', - 'audit_syslog_retry_dead_letters($delivery_ids)' + 'audit_syslog_retry_dead_letters($delivery_ids)', + "if (!audit_syslog_enabled()) {\n\t\treturn;", + "if (audit_syslog_enabled() && db_table_exists('audit_syslog_delivery'))", + 'cacti_sizeof($syslog) > 0', + '$syslog[\'state\'] ?? \'unknown\'', + '$syslog[\'attempts\'] ?? 0', + '$syslog[\'sent_time\'] ?? \'\'', + '$syslog[\'last_error\'] ?? \'\'' ]; foreach ($required_controller_guards as $guard) { From fc364d33c4b0b07fa02c71475960466b3830d470 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:32:54 -0400 Subject: [PATCH 35/36] Persist syslog batch settings And fix bruteforce detection settings --- audit_functions.php | 6 +++++- setup.php | 23 ++++++++++++----------- tests/auth_audit_test.php | 10 ++++++++++ tests/controller_security_test.php | 2 ++ 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/audit_functions.php b/audit_functions.php index 95ba38e..e871c81 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -1243,7 +1243,11 @@ function audit_enforce_syslog_settings_request(): void { if (strpos($name, 'audit_syslog_') === 0) { $has_syslog_fields = true; - } elseif (strpos($name, 'audit_auth_') === 0 || strpos($name, 'audit_brute_force_') === 0) { + } elseif ( + strpos($name, 'audit_auth_') === 0 || + strpos($name, 'audit_brute_force_') === 0 || + $name === 'audit_user_log_batch_size' + ) { $has_auth_fields = true; } } diff --git a/setup.php b/setup.php index 8c7761b..6ebb661 100644 --- a/setup.php +++ b/setup.php @@ -58,7 +58,8 @@ function audit_persist_auth_defaults(): void { 'audit_brute_force_enabled' => 'on', 'audit_brute_force_window_minutes' => '5', 'audit_brute_force_threshold' => '10', - 'audit_brute_force_last_alert' => '' + 'audit_brute_force_last_alert' => '', + 'audit_user_log_batch_size' => '1000' ]; foreach ($defaults as $name => $value) { @@ -707,15 +708,15 @@ function audit_config_settings(): void { 'max_length' => '4', 'size' => '8' ], - 'audit_user_log_batch_size' => [ - 'friendly_name' => __('User Log Ingestion Batch Size', 'audit'), - 'description' => __('Maximum user_log rows ingested per poller cycle, from 1 through 5000. Larger batches process backlogs faster but increase poller runtime.', 'audit'), - 'method' => 'textbox', - 'default' => '1000', - 'max_length' => '4', - 'size' => '8' - ], - ]; + 'audit_user_log_batch_size' => [ + 'friendly_name' => __('User Log Ingestion Batch Size', 'audit'), + 'description' => __('Maximum user_log rows ingested per poller cycle, from 1 through 5000. Larger batches process backlogs faster but increase poller runtime.', 'audit'), + 'method' => 'textbox', + 'default' => '1000', + 'max_length' => '4', + 'size' => '8' + ], + ]; $facility_options = []; @@ -890,7 +891,7 @@ function audit_config_settings(): void { ] ]; - $temp = array_merge($temp, $syslog); + $temp = array_merge($temp, $auth_settings, $syslog); } $tabs['audit'] = __('Audit', 'audit'); diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php index fecf15b..b069d5b 100644 --- a/tests/auth_audit_test.php +++ b/tests/auth_audit_test.php @@ -665,6 +665,12 @@ function audit_test_reset_state(): void { // source assertion that the auth-only unauthorized path emits the generic // audit.configuration.denied event, distinct from the syslog-specific one. $functions_source = file_get_contents(dirname(__DIR__) . '/audit_functions.php'); + +if (!is_string($functions_source)) { + fwrite(STDERR, 'Unable to read audit_functions.php for settings authorization checks.' . PHP_EOL); + exit(1); +} + audit_test_assert_true( strpos($functions_source, "'audit.configuration.denied'") !== false, 'The generic audit.configuration.denied event must be present for unauthorized auth-settings saves.' @@ -679,5 +685,9 @@ function audit_test_reset_state(): void { && strpos($functions_source, 'audit_admin_required') !== false, 'The unauthorized auth-settings save must record an audit_admin_required denied event.' ); +audit_test_assert_true( + preg_match('/\\$name\\s*===\\s*[\'"]audit_user_log_batch_size[\'"]/', $functions_source) === 1, + 'The user_log ingestion batch-size setting must receive the same Audit Log Admin protection as authentication settings.' +); print "Auth audit tests passed.\n"; diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index c6de27d..6188e54 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -55,6 +55,8 @@ 'audit_auth_log_enabled', 'audit_brute_force_enabled', 'audit_user_log_batch_size', + 'array_merge($temp, $auth_settings, $syslog)', + "'audit_user_log_batch_size' => '1000'", 'audit_persist_auth_defaults', 'CREATE TABLE IF NOT EXISTS `audit_user_log_state`', 'DROP TABLE IF EXISTS audit_user_log_state', From 16402adaede56bb9de781fc670b133e9adb05d0f Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Tue, 28 Jul 2026 20:27:37 -0400 Subject: [PATCH 36/36] Address PR 64 review feedback --- audit.php | 2 +- setup.php | 5 +++-- tests/Security/SetupStructureTest.php | 5 +++++ tests/controller_security_test.php | 1 + 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/audit.php b/audit.php index 5347f98..f3d83b7 100644 --- a/audit.php +++ b/audit.php @@ -128,7 +128,7 @@ WHERE id = ?', [get_filter_request_var('id')]); - if (!is_array($data)) { + if (!is_array($data) || $data === []) { http_response_code(404); print html_escape(__('Audit event not found.', 'audit')); diff --git a/setup.php b/setup.php index 6ebb661..7722254 100644 --- a/setup.php +++ b/setup.php @@ -542,9 +542,10 @@ function audit_upgrade_event_schema(mixed $rcnn_id = false): void { */ function plugin_audit_version(): array { global $config; - $info = parse_ini_file($config['base_path'] . '/plugins/audit/INFO', true); + $info = parse_ini_file($config['base_path'] . '/plugins/audit/INFO', true); + $plugin_info = is_array($info) ? ($info['info'] ?? null) : null; - return is_array($info) ? $info['info'] : []; + return is_array($plugin_info) ? $plugin_info : []; } function audit_log_valid_event(): bool { diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php index 88e36d5..e3799d5 100644 --- a/tests/Security/SetupStructureTest.php +++ b/tests/Security/SetupStructureTest.php @@ -29,6 +29,11 @@ expect($source)->toContain("'info'"); }); + it('returns an array when plugin info is missing or malformed', function () use ($source) { + expect($source)->toContain("\$info['info'] ?? null"); + expect($source)->toContain('is_array($plugin_info) ? $plugin_info : []'); + }); + it('INFO file defines name and version keys', function () { $info_file = realpath(__DIR__ . '/../../INFO'); expect($info_file)->not->toBeFalse('INFO file must exist'); diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 6188e54..f8ea35c 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -17,6 +17,7 @@ "case 'syslog_retry':", 'audit_syslog_test_delivery()', 'audit_syslog_retry_dead_letters($delivery_ids)', + 'if (!is_array($data) || $data === [])', "if (!audit_syslog_enabled()) {\n\t\treturn;", "if (audit_syslog_enabled() && db_table_exists('audit_syslog_delivery'))", 'cacti_sizeof($syslog) > 0',
'; @@ -182,7 +185,7 @@ function audit_render_event_details(array $data): string { LIMIT 1', [$data['id']]); - if (cacti_sizeof($syslog)) { + if (is_array($syslog)) { $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($syslog['state']) . ''; $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . (int) $syslog['attempts'] . ''; @@ -350,47 +353,52 @@ function audit_export_rows(): void { header('X-Content-Type-Options: nosniff'); $output = fopen('php://output', 'w'); - fputcsv($output, ['event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'page', 'user_id', 'username', 'action', 'request_status', 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', 'external_status', 'external_error', 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', 'completed_time', 'duration_ms', 'integrity_hash', 'post', 'details'], ',', '"', ''); - - foreach ($events as $event) { - if ($event['action'] == 'cli') { - $poster = $event['post']; - } else { - $post = audit_json_decode($event['post'], $json_error); - $poster = is_array($post) ? json_encode($post, JSON_INVALID_UTF8_SUBSTITUTE) : $event['post']; + + if ($output !== false) { + fputcsv($output, ['event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'page', 'user_id', 'username', 'action', 'request_status', 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', 'external_status', 'external_error', 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', 'completed_time', 'duration_ms', 'integrity_hash', 'post', 'details'], ',', '"', ''); + + if (is_array($events)) { + foreach ($events as $event) { + if ($event['action'] == 'cli') { + $poster = $event['post']; + } else { + $post = audit_json_decode($event['post'], $json_error); + $poster = is_array($post) ? json_encode($post, JSON_INVALID_UTF8_SUBSTITUTE) : $event['post']; + } + + fputcsv($output, array_map('audit_csv_safe_cell', [ + $event['event_uuid'], + $event['correlation_id'], + $event['event_type'], + $event['event_category'], + $event['severity'], + $event['page'], + $event['user_id'], + get_username($event['user_id']), + $event['action'], + $event['request_status'], + $event['operation_outcome'], + $event['outcome_reason'], + $event['target_type'], + $event['target_id'], + $event['external_status'], + $event['external_error'], + $event['ip_address'], + $event['user_agent'], + $event['http_method'], + $event['http_status'], + $event['event_time'], + $event['completed_time'], + $event['duration_ms'], + $event['integrity_hash'], + $poster, + $event['details'] + ]), ',', '"', ''); + } } - fputcsv($output, array_map('audit_csv_safe_cell', [ - $event['event_uuid'], - $event['correlation_id'], - $event['event_type'], - $event['event_category'], - $event['severity'], - $event['page'], - $event['user_id'], - get_username($event['user_id']), - $event['action'], - $event['request_status'], - $event['operation_outcome'], - $event['outcome_reason'], - $event['target_type'], - $event['target_id'], - $event['external_status'], - $event['external_error'], - $event['ip_address'], - $event['user_agent'], - $event['http_method'], - $event['http_status'], - $event['event_time'], - $event['completed_time'], - $event['duration_ms'], - $event['integrity_hash'], - $poster, - $event['details'] - ]), ',', '"', ''); + fclose($output); } - - fclose($output); } } @@ -669,29 +677,31 @@ function audit_log(): void { $i = 0; if (cacti_sizeof($events)) { - foreach ($events as $e) { - if ($e['action'] == 'cli') { - form_alternate_row('line' . $e['id'], false); - form_selectable_ecell($e['page'], $e['id']); - form_selectable_ecell($e['user_agent'], $e['id']); - form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); - form_selectable_ecell($e['request_status'], $e['id']); - form_selectable_ecell($e['external_status'], $e['id']); - form_selectable_cell(__('N/A', 'audit'), $e['id']); - form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); - form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); - form_end_row(); - } else { - form_alternate_row('line' . $e['id'], false); - form_selectable_cell(filter_value($e['page'], get_request_var('filter')), $e['id']); - form_selectable_ecell($e['username'], $e['id']); - form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); - form_selectable_ecell($e['request_status'], $e['id']); - form_selectable_ecell($e['external_status'], $e['id']); - form_selectable_ecell($e['user_agent'], $e['id']); - form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); - form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); - form_end_row(); + if (is_array($events)) { + foreach ($events as $e) { + if ($e['action'] == 'cli') { + form_alternate_row('line' . $e['id'], false); + form_selectable_ecell($e['page'], $e['id']); + form_selectable_ecell($e['user_agent'], $e['id']); + form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); + form_selectable_ecell($e['request_status'], $e['id']); + form_selectable_ecell($e['external_status'], $e['id']); + form_selectable_cell(__('N/A', 'audit'), $e['id']); + form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); + form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); + form_end_row(); + } else { + form_alternate_row('line' . $e['id'], false); + form_selectable_cell(filter_value($e['page'], get_request_var('filter')), $e['id']); + form_selectable_ecell($e['username'], $e['id']); + form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); + form_selectable_ecell($e['request_status'], $e['id']); + form_selectable_ecell($e['external_status'], $e['id']); + form_selectable_ecell($e['user_agent'], $e['id']); + form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); + form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); + form_end_row(); + } } } } else { diff --git a/audit_functions.php b/audit_functions.php index 96b1af3..70f070a 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -6,6 +6,9 @@ function audit_user_is_admin(): bool { return api_plugin_user_realm_auth('audit_manage.php'); } +/** + * @param array $selected_items + */ function audit_process_page_data(string $page, mixed $drop_action, array $selected_items): string { $objects = []; @@ -45,9 +48,11 @@ function audit_process_page_data(string $page, mixed $drop_action, array $select WHERE id IN (?)', [$item]); - foreach ($result as &$row) { - $row['snmp'] = ($row['snmp'] == 1) ? 'UP' : 'Down'; - $row['up'] = ($row['up'] == 1) ? 'Yes' : 'No'; + if (is_array($result)) { + foreach ($result as &$row) { + $row['snmp'] = ($row['snmp'] == 1) ? 'UP' : 'Down'; + $row['up'] = ($row['up'] == 1) ? 'Yes' : 'No'; + } } $objects[] = $result; @@ -182,6 +187,7 @@ function audit_bound_log_data(mixed $data, int $depth = 0, ?object $state = null $bounded = []; foreach ($data as $key => $value) { + // @phpstan-ignore-next-line (property.notFound: dynamic property on stdClass state object) if ($state->fields >= 1000) { $bounded['audit_truncated'] = 'Additional fields were omitted.'; @@ -202,6 +208,10 @@ function audit_bound_log_data(mixed $data, int $depth = 0, ?object $state = null return $data; } +/** + * @param array $arguments + * @return array + */ function audit_redact_cli_arguments(array $arguments): array { $redacted = []; $redact_next = false; @@ -227,7 +237,7 @@ function audit_redact_cli_arguments(array $arguments): array { continue; } - $redacted[] = preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $argument); + $redacted[] = preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $argument) ?? $argument; } return $redacted; @@ -237,7 +247,9 @@ function audit_json_encode(mixed $data, int $options = 0): string { $json = json_encode(audit_bound_log_data($data), JSON_INVALID_UTF8_SUBSTITUTE | $options, 16); if ($json === false) { - return json_encode(['audit_encoding_error' => json_last_error_msg()]); + $fallback = json_encode(['audit_encoding_error' => json_last_error_msg()]); + + return $fallback !== false ? $fallback : '{}'; } return $json; @@ -288,6 +300,9 @@ function audit_utc_time(?float $microtime = null): string { return gmdate('Y-m-d H:i:s', $seconds) . '.' . sprintf('%06d', $micros); } +/** + * @param array $event + */ function audit_event_integrity_hash(array $event): string { $material = [ 'event_uuid' => $event['event_uuid'] ?? '', @@ -307,14 +322,18 @@ function audit_event_integrity_hash(array $event): string { function audit_event_type_for_request(mixed $page, mixed $action): string { $page_name = preg_replace('/\.php$/', '', (string) $page); - $page_name = preg_replace('/[^a-z0-9_]+/i', '_', $page_name); + $page_name = preg_replace('/[^a-z0-9_]+/i', '_', $page_name ?? ''); $verb = preg_replace('/[^a-z0-9_]+/i', '_', strtolower((string) $action)); - $verb = trim($verb, '_'); + $verb = trim($verb ?? '', '_'); return 'cacti.' . ($page_name !== '' ? $page_name : 'request') . '.' . ($verb !== '' && $verb !== 'none' ? $verb : 'submitted'); } +/** + * @param array $event + * @return array + */ function audit_external_event_data(array $event): array { $fields = [ 'id', 'event_uuid', 'correlation_id', 'event_type', 'event_category', @@ -333,6 +352,9 @@ function audit_external_event_data(array $event): array { return $data; } +/** + * @param array $data + */ function audit_external_log_format(array $data, string $format = 'json'): string { if ($format === 'text') { $fields = []; @@ -388,6 +410,9 @@ function audit_retention_cutoff(mixed $retention, ?DateTimeImmutable $now = null return $now->sub(new DateInterval('P' . max(0, (int) $retention) . 'D')); } +/** + * @return array + */ function audit_append_external_log(string $path, string $message): array { if ($path == '' || !is_file($path) || is_link($path)) { return ['status' => 'failed', 'error' => 'Destination is not a regular file or is a symbolic link.']; @@ -420,7 +445,7 @@ function audit_deliver_external_event(int $id): void { $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); - if (!cacti_sizeof($event) || $event['request_status'] == 'started') { + if (is_array($event) && $event['request_status'] == 'started') { return; } @@ -433,7 +458,7 @@ function audit_deliver_external_event(int $id): void { } $format = read_config_option('audit_log_external_format') === 'text' ? 'text' : 'json'; - $message = audit_external_log_format(audit_external_event_data($event), $format); + $message = audit_external_log_format(audit_external_event_data(is_array($event) ? $event : []), $format); $delivery = audit_append_external_log($path, $message); audit_set_external_status($id, $delivery['status'], $delivery['error']); } @@ -459,17 +484,22 @@ function audit_retry_external_logs(): void { ORDER BY id LIMIT 100"); - foreach ($events as $event) { - $message = audit_external_log_format(audit_external_event_data($event), $format); - $delivery = audit_append_external_log($path, $message); - audit_set_external_status($event['id'], $delivery['status'], $delivery['error']); + if (is_array($events)) { + foreach ($events as $event) { + $message = audit_external_log_format(audit_external_event_data($event), $format); + $delivery = audit_append_external_log($path, $message); + audit_set_external_status($event['id'], $delivery['status'], $delivery['error']); - if ($delivery['status'] != 'delivered') { - break; + if ($delivery['status'] != 'delivered') { + break; + } } } } +/** + * @param array $error + */ function audit_request_status(?array $error = null, int $status_code = 200): string { $fatal_types = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; @@ -481,6 +511,10 @@ function audit_request_status(?array $error = null, int $status_code = 200): str return 'completed'; } +/** + * @param array $post + * @return array|null + */ function audit_operation_verifier_for_request(string $page, array $post): ?array { if ($page != 'user_admin.php' || !array_key_exists('save_component_realm_perms', $post)) { return null; @@ -525,6 +559,9 @@ function audit_operation_verifier_for_request(string $page, array $post): ?array ]; } +/** + * @return array + */ function audit_verify_operation(mixed $verifier): array { if (!is_array($verifier) || empty($verifier['type'])) { return ['outcome' => 'unknown', 'reason' => null]; @@ -583,6 +620,9 @@ function audit_verify_operation(mixed $verifier): array { return ['outcome' => 'failure', 'reason' => 'realm_permissions_mismatch']; } +/** + * @param array|null $verifier + */ function audit_finalize_request(int $id, ?float $started_at = null, ?array $verifier = null): void { $status_code = http_response_code(); $status_code = is_int($status_code) ? $status_code : 200; @@ -612,7 +652,7 @@ function audit_finalize_request(int $id, ?float $started_at = null, ?array $veri $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); - if (cacti_sizeof($event)) { + if (is_array($event)) { db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', [audit_event_integrity_hash($event), $id]); } @@ -621,6 +661,9 @@ function audit_finalize_request(int $id, ?float $started_at = null, ?array $veri audit_enqueue_syslog_event($id); } +/** + * @param array $options + */ function audit_record_event(string $event_type, array $options = []): int { if (read_config_option('audit_enabled') != 'on') { return 0; @@ -660,7 +703,7 @@ function audit_record_event(string $event_type, array $options = []): int { $id = db_fetch_insert_id(); $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); - if (cacti_sizeof($event)) { + if (is_array($event)) { db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', [audit_event_integrity_hash($event), $id]); } @@ -741,7 +784,7 @@ function audit_enforce_syslog_settings_request(): void { $config = audit_syslog_config($overrides); $enabling = isset($post['audit_syslog_enabled']) && $post['audit_syslog_enabled'] === 'on'; - $configuring = trim($overrides['receiver']) !== ''; + $configuring = trim($overrides['receiver'] ?? '') !== ''; if (($enabling || $configuring) && !$config['valid']) { audit_record_event('audit.syslog.configuration.rejected', [ diff --git a/audit_syslog.php b/audit_syslog.php index 285b50f..10ff4f0 100644 --- a/audit_syslog.php +++ b/audit_syslog.php @@ -25,6 +25,9 @@ function audit_syslog_read_setting(string $name, mixed $default): mixed { return $value === '' || $value === null ? $default : $value; } +/** + * @param array $errors + */ function audit_syslog_bounded_integer(mixed $value, int $default, int $minimum, int $maximum, array &$errors, string $name): int { if (!is_scalar($value) || !preg_match('/^[0-9]+$/', (string) $value)) { $errors[] = $name . '_invalid'; @@ -79,6 +82,9 @@ function audit_syslog_valid_header_value(string $value, int $maximum): bool { !preg_match('/[^\\x21-\\x7e]|[\\[\\]="]/', $value); } +/** + * @param array $errors + */ function audit_syslog_validate_optional_file(string $path, string $name, array &$errors): string { if ($path === '') { return ''; @@ -91,6 +97,10 @@ function audit_syslog_validate_optional_file(string $path, string $name, array & return $path; } +/** + * @param array $overrides + * @return array + */ function audit_syslog_config(array $overrides = []): array { $defaults = [ 'receiver' => '', @@ -122,20 +132,20 @@ function audit_syslog_config(array $overrides = []): array { } $errors = []; - $receiver = trim((string) $values['receiver']); + $receiver = trim((string) ($values['receiver'] ?? '')); if (!audit_syslog_valid_receiver($receiver)) { $errors[] = 'receiver_invalid'; } - $transport = strtolower((string) $values['transport']); + $transport = strtolower((string) ($values['transport'] ?? '')); if (!in_array($transport, ['udp', 'tcp', 'tls'], true)) { $errors[] = 'transport_invalid'; $transport = 'udp'; } - $format = strtolower((string) $values['format']); + $format = strtolower((string) ($values['format'] ?? '')); if (!in_array($format, ['rfc5424', 'cef', 'json'], true)) { $errors[] = 'format_invalid'; @@ -143,47 +153,47 @@ function audit_syslog_config(array $overrides = []): array { } $facility_map = audit_syslog_facilities(); - $facility = strtolower((string) $values['facility']); + $facility = strtolower((string) ($values['facility'] ?? '')); if (!isset($facility_map[$facility])) { $errors[] = 'facility_invalid'; $facility = 'local0'; } - $application = trim((string) $values['application']); + $application = trim((string) ($values['application'] ?? '')); if (!audit_syslog_valid_header_value($application, 48)) { $errors[] = 'application_invalid'; $application = 'cacti-audit'; } - $node_id = trim((string) $values['node_id']); + $node_id = trim((string) ($values['node_id'] ?? '')); if (!audit_syslog_valid_header_value($node_id, 255)) { $errors[] = 'node_id_invalid'; $node_id = 'cacti'; } - $port = trim((string) $values['port']) === '' + $port = trim((string) ($values['port'] ?? '')) === '' ? ($transport === 'tls' ? 6514 : 514) - : audit_syslog_bounded_integer($values['port'], $transport === 'tls' ? 6514 : 514, 1, 65535, $errors, 'port'); - $timeout = audit_syslog_bounded_integer($values['timeout'], 5, 1, 30, $errors, 'timeout'); - $udp_max_size = audit_syslog_bounded_integer($values['udp_max_size'], 8192, 512, 65507, $errors, 'udp_max_size'); - $retry_base = audit_syslog_bounded_integer($values['retry_base'], 30, 1, 3600, $errors, 'retry_base'); - $retry_max = audit_syslog_bounded_integer($values['retry_max'], 3600, 1, 86400, $errors, 'retry_max'); - $max_attempts = audit_syslog_bounded_integer($values['max_attempts'], 10, 1, 100, $errors, 'max_attempts'); - $batch_size = audit_syslog_bounded_integer($values['batch_size'], 100, 1, 1000, $errors, 'batch_size'); - $pending_age_warning = audit_syslog_bounded_integer($values['pending_age_warning'], 900, 60, 604800, $errors, 'pending_age_warning'); - $dead_letter_warning = audit_syslog_bounded_integer($values['dead_letter_warning'], 1, 1, 1000000, $errors, 'dead_letter_warning'); + : audit_syslog_bounded_integer($values['port'] ?? '', $transport === 'tls' ? 6514 : 514, 1, 65535, $errors, 'port'); + $timeout = audit_syslog_bounded_integer($values['timeout'] ?? 5, 5, 1, 30, $errors, 'timeout'); + $udp_max_size = audit_syslog_bounded_integer($values['udp_max_size'] ?? 8192, 8192, 512, 65507, $errors, 'udp_max_size'); + $retry_base = audit_syslog_bounded_integer($values['retry_base'] ?? 30, 30, 1, 3600, $errors, 'retry_base'); + $retry_max = audit_syslog_bounded_integer($values['retry_max'] ?? 3600, 3600, 1, 86400, $errors, 'retry_max'); + $max_attempts = audit_syslog_bounded_integer($values['max_attempts'] ?? 10, 10, 1, 100, $errors, 'max_attempts'); + $batch_size = audit_syslog_bounded_integer($values['batch_size'] ?? 100, 100, 1, 1000, $errors, 'batch_size'); + $pending_age_warning = audit_syslog_bounded_integer($values['pending_age_warning'] ?? 900, 900, 60, 604800, $errors, 'pending_age_warning'); + $dead_letter_warning = audit_syslog_bounded_integer($values['dead_letter_warning'] ?? 1, 1, 1, 1000000, $errors, 'dead_letter_warning'); if ($retry_max < $retry_base) { $errors[] = 'retry_max_less_than_base'; $retry_max = $retry_base; } - $tls_ca_file = trim((string) $values['tls_ca_file']); - $tls_client_cert = trim((string) $values['tls_client_cert']); - $tls_client_key = trim((string) $values['tls_client_key']); + $tls_ca_file = trim((string) ($values['tls_ca_file'] ?? '')); + $tls_client_cert = trim((string) ($values['tls_client_cert'] ?? '')); + $tls_client_key = trim((string) ($values['tls_client_key'] ?? '')); if ($transport === 'tls') { audit_syslog_validate_optional_file($tls_ca_file, 'tls_ca_file', $errors); @@ -225,6 +235,9 @@ function audit_syslog_config(array $overrides = []): array { return $config; } +/** + * @param array $config + */ function audit_syslog_destination_fingerprint(array $config): string { $identity = [ 'receiver' => $config['receiver'], @@ -241,6 +254,9 @@ function audit_syslog_destination_fingerprint(array $config): string { return hash('sha256', audit_json_encode($identity, JSON_UNESCAPED_SLASHES)); } +/** + * @return array + */ function audit_syslog_facilities(): array { return [ 'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3, @@ -265,7 +281,7 @@ function audit_syslog_severity_code(mixed $severity): int { function audit_syslog_header_token(mixed $value, int $maximum, string $fallback): string { $value = preg_replace('/[^\\x21-\\x3c\\x3e-\\x5a\\x5e-\\x7e]/', '_', (string) $value); - $value = substr($value, 0, $maximum); + $value = substr($value ?? '', 0, $maximum); return $value === '' ? $fallback : $value; } @@ -273,7 +289,7 @@ function audit_syslog_header_token(mixed $value, int $maximum, string $fallback) function audit_syslog_structured_value(mixed $value): string { $value = preg_replace('/[\\x00-\\x1f\\x7f]/', ' ', (string) $value); - return str_replace(['\\', '"', ']'], ['\\\\', '\\"', '\\]'], $value); + return str_replace(['\\', '"', ']'], ['\\\\', '\\"', '\\]'], $value ?? ''); } function audit_syslog_timestamp(mixed $value): string { @@ -286,6 +302,11 @@ function audit_syslog_timestamp(mixed $value): string { return gmdate('Y-m-d\\TH:i:s\\Z'); } +/** + * @param array $event + * @param array $config + * @return array + */ function audit_syslog_normalized_data(array $event, array $config): array { $data = audit_external_event_data($event); $data['node_id'] = $config['node_id']; @@ -345,6 +366,10 @@ function audit_syslog_cef_event_field(mixed $value): string { return audit_redact_sensitive_value((string) $value); } +/** + * @param array $event + * @param array $config + */ function audit_syslog_cef_payload(array $event, array $config): string { $severity = audit_syslog_cef_severity($event['severity'] ?? 'info'); $header = [ @@ -387,6 +412,10 @@ function audit_syslog_cef_payload(array $event, array $config): string { return implode('|', $encoded_header) . '|' . implode(' ', $encoded_extension); } +/** + * @param array $event + * @param array $config + */ function audit_syslog_message_payload(array $event, array $config): string { if ($config['format'] === 'cef') { return audit_syslog_cef_payload($event, $config); @@ -399,6 +428,11 @@ function audit_syslog_message_payload(array $event, array $config): string { return 'Audit event ' . (string) ($event['event_uuid'] ?? ''); } +/** + * @param array $event + * @param array $config + * @return array + */ function audit_syslog_record(array $event, array $config): array { if (empty($config['valid'])) { return [ @@ -477,6 +511,9 @@ function audit_syslog_frame(string $record, string $transport): string { return $record; } +/** + * @param array $config + */ function audit_syslog_socket_target(array $config): string { $receiver = $config['receiver']; @@ -489,7 +526,7 @@ function audit_syslog_socket_target(array $config): string { return $scheme . '://' . $receiver . ':' . $config['port']; } -function audit_syslog_stream_operation(callable $operation, ?string &$warning = null): mixed { +function audit_syslog_stream_operation(callable $operation, string &$warning = ''): mixed { $warning = ''; $handler = function ($severity, $message) use (&$warning) { $warning = audit_syslog_bounded_error($message); @@ -510,6 +547,10 @@ function audit_syslog_stream_operation(callable $operation, ?string &$warning = } } +/** + * @param array $config + * @return array + */ function audit_syslog_open_socket(array $config): array { $context_options = []; @@ -576,15 +617,18 @@ function audit_syslog_open_socket(array $config): array { function audit_syslog_bounded_error(mixed $error): string { $error = preg_replace('/[\\x00-\\x1f\\x7f]+/', ' ', (string) $error); - return substr(trim($error), 0, 1024); + return substr(trim($error ?? ''), 0, 1024); } -function audit_syslog_fwrite(mixed $socket, string $message, ?string &$warning = null): int|false { +function audit_syslog_fwrite(mixed $socket, string $message, string &$warning = ''): int|false { return audit_syslog_stream_operation(function () use ($socket, $message) { return fwrite($socket, $message); }, $warning); } +/** + * @return array + */ function audit_syslog_write(mixed $socket, string $message, string $transport): array { if (!is_resource($socket)) { return ['status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.']; @@ -625,6 +669,11 @@ function audit_syslog_write(mixed $socket, string $message, string $transport): return ['status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '']; } +/** + * @param array $event + * @param array $config + * @return array + */ function audit_syslog_send_event(array $event, array $config, mixed &$socket = null): array { $formatted = audit_syslog_record($event, $config); @@ -669,7 +718,7 @@ function audit_enqueue_syslog_event(int $audit_id): void { WHERE id = ?', [$audit_id]); - if (!cacti_sizeof($event) || $event['request_status'] === 'started' || $event['event_uuid'] === '') { + if (!is_array($event) || $event['request_status'] === 'started' || $event['event_uuid'] === '') { return; } @@ -687,6 +736,11 @@ function audit_enqueue_syslog_event(int $audit_id): void { ]); } +/** + * @param array $config + * @param array $delivery + * @return array + */ function audit_syslog_delivery_config(array $config, array $delivery): array { if (isset($delivery['delivery_node_id']) && $delivery['delivery_node_id'] !== '') { $config['node_id'] = $delivery['delivery_node_id']; @@ -701,6 +755,9 @@ function audit_syslog_delivery_config(array $config, array $delivery): array { return $config; } +/** + * @param array $config + */ function audit_syslog_retry_delay(mixed $attempt, array $config): int { $exponent = min(max(0, (int) $attempt - 1), 30); $delay = $config['retry_base'] * pow(2, $exponent); @@ -708,6 +765,11 @@ function audit_syslog_retry_delay(mixed $attempt, array $config): int { return (int) min($config['retry_max'], $delay); } +/** + * @param array $delivery + * @param array $result + * @param array $config + */ function audit_syslog_update_delivery(array $delivery, array $result, array $config): void { $attempts = (int) $delivery['attempts'] + 1; $error = isset($result['error']) ? audit_syslog_bounded_error($result['error']) : ''; @@ -774,13 +836,15 @@ function audit_process_syslog_queue(): void { []); $socket = null; - foreach ($deliveries as $delivery) { - $delivery_config = audit_syslog_delivery_config($config, $delivery); - $result = audit_syslog_send_event($delivery, $delivery_config, $socket); - audit_syslog_update_delivery($delivery, $result, $delivery_config); + if (is_array($deliveries)) { + foreach ($deliveries as $delivery) { + $delivery_config = audit_syslog_delivery_config($config, $delivery); + $result = audit_syslog_send_event($delivery, $delivery_config, $socket); + audit_syslog_update_delivery($delivery, $result, $delivery_config); - if ($result['status'] !== 'sent_unconfirmed' && empty($result['permanent'])) { - break; + if ($result['status'] !== 'sent_unconfirmed' && empty($result['permanent'])) { + break; + } } } @@ -791,6 +855,9 @@ function audit_process_syslog_queue(): void { audit_syslog_check_health($config); } +/** + * @return array + */ function audit_syslog_health(): array { if (!db_table_exists('audit_syslog_delivery')) { return [ @@ -829,6 +896,9 @@ function audit_syslog_health(): array { ]; } +/** + * @param array|null $config + */ function audit_syslog_check_health(?array $config = null): void { if (!audit_syslog_enabled()) { return; @@ -851,6 +921,9 @@ function audit_syslog_check_health(?array $config = null): void { } } +/** + * @param array $delivery_ids + */ function audit_syslog_retry_dead_letters(array $delivery_ids = []): int { if (!db_table_exists('audit_syslog_delivery')) { return 0; @@ -895,6 +968,9 @@ function audit_syslog_retry_dead_letters(array $delivery_ids = []): int { return db_affected_rows(); } +/** + * @return array + */ function audit_syslog_test_delivery(): array { $config = audit_syslog_config(); $event = [ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8366ae2..782fd76 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,2 +1,955 @@ parameters: - ignoreErrors: [] \ No newline at end of file + ignoreErrors: + - + message: '#^Call to function cacti_log\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: audit.php + + - + message: '#^Call to function cacti_log\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 4 + path: audit_functions.php + + - + message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: audit_functions.php + + - + message: '#^Call to function cacti_log\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: audit_syslog.php + + - + message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: audit_syslog.php + + - + message: '#^Call to function cacti_log\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 4 + path: setup.php + + - + message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: setup.php + + - + message: '#^Parameter \#1 \$filename of function file_get_contents expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/Php81SyntaxTest.php + + - + message: '#^Parameter \#1 \$filename of function is_readable expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/Php81SyntaxTest.php + + - + message: '#^Parameter \#2 \$subject of function preg_match expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/Php81SyntaxTest.php + + - + message: '#^Parameter \#1 \$filename of function file_get_contents expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/PreparedStatementConsistencyTest.php + + - + message: '#^Parameter \#2 \$subject of function preg_match expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/PreparedStatementConsistencyTest.php + + - + message: '#^Cannot access offset ''info'' on array\|false\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: tests/Security/SetupStructureTest.php + + - + message: '#^Parameter \#1 \$filename of function file_get_contents expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/SetupStructureTest.php + + - + message: '#^Parameter \#1 \$filename of function parse_ini_file expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/SetupStructureTest.php + + - + message: '#^Function __\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __\(\) has parameter \$domain with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __\(\) has parameter \$text with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __esc\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __esc\(\) has parameter \$domain with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __esc\(\) has parameter \$text with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_add_column\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_add_column\(\) has parameter \$data with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_add_column\(\) has parameter \$plugin with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_add_column\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_table_create\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_table_create\(\) has parameter \$data with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_table_create\(\) has parameter \$plugin with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_table_create\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has parameter \$also_print with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has parameter \$level with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has parameter \$log_type with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has parameter \$message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_sizeof\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_sizeof\(\) has parameter \$array with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_affected_rows\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_column_exists\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_column_exists\(\) has parameter \$column with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_column_exists\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_index_exists\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_index_exists\(\) has parameter \$index with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_index_exists\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_table_exists\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_table_exists\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$error with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$optional with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$regex with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$value with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_filter_request_var\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_filter_request_var\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_nfilter_request_var\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_nfilter_request_var\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_request_var\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_request_var\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function html_escape\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function html_escape\(\) has parameter \$string with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function is_error_message\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function is_realm_allowed\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function is_realm_allowed\(\) has parameter \$realm with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function raise_message\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function raise_message\(\) has parameter \$id with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function raise_message\(\) has parameter \$level with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function raise_message\(\) has parameter \$text with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function read_config_option\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function read_config_option\(\) has parameter \$force with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function read_config_option\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function set_config_option\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function set_config_option\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function set_config_option\(\) has parameter \$value with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function sql_save\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function sql_save\(\) has parameter \$array with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function sql_save\(\) has parameter \$key with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function sql_save\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|false given\.$#' + identifier: argument.type + count: 11 + path: tests/controller_security_test.php + + - + message: '#^Parameter \#1 \$haystack of function substr_count expects string, string\|false given\.$#' + identifier: argument.type + count: 2 + path: tests/controller_security_test.php + + - + message: '#^Function api_plugin_user_realm_auth\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function api_plugin_user_realm_auth\(\) has parameter \$filename with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function audit_test_assert_same\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function audit_test_assert_same\(\) has parameter \$actual with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function audit_test_assert_same\(\) has parameter \$expected with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function audit_test_assert_same\(\) has parameter \$message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Offset ''type'' might not exist on array\\|null\.$#' + identifier: offsetAccess.notFound + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Cannot access offset 0 on array\\|false\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 9 + path: tests/syslog_functions_test.php + + - + message: '#^Cannot access offset 1 on array\\|false\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 4 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_assert\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_assert\(\) has parameter \$condition with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_assert\(\) has parameter \$message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_config\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_config\(\) has parameter \$overrides with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_event\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_remove_tls_material\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_remove_tls_material\(\) has parameter \$material with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_tls_material\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_tls_server\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_tls_server\(\) has parameter \$material with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_tls_server\(\) has parameter \$port with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_udp_server\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_udp_server\(\) has parameter \$error with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_udp_server\(\) has parameter \$error_message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_udp_server\(\) has parameter \$port with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$csr of function openssl_csr_sign expects OpenSSLCertificateSigningRequest\|string, OpenSSLCertificateSigningRequest\|true given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$haystack of function strrpos expects string, string\|false given\.$#' + identifier: argument.type + count: 3 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$socket of function socket_set_option expects Socket, Socket\|false given\.$#' + identifier: argument.type + count: 2 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$socket of function stream_socket_accept expects resource, resource\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$socket of function stream_socket_get_name expects resource, resource\|false given\.$#' + identifier: argument.type + count: 2 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$stream of function fclose expects resource, resource\|false given\.$#' + identifier: argument.type + count: 3 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$stream of function fread expects resource, resource\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$stream of function stream_set_timeout expects resource, resource\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$string of function substr expects string, string\|false given\.$#' + identifier: argument.type + count: 3 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_queue_assert\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function audit_queue_assert\(\) has parameter \$condition with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function audit_queue_assert\(\) has parameter \$message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function cacti_sizeof\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function cacti_sizeof\(\) has parameter \$value with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_affected_rows\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_fetch_row_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_fetch_row_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_fetch_row_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_table_exists\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_table_exists\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function read_config_option\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function read_config_option\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Offset 0 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 15 + path: tests/syslog_queue_test.php + + - + message: '#^Parameter \#1 \$delivery_ids of function audit_syslog_retry_dead_letters expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_queue_test.php \ No newline at end of file diff --git a/phpstan/stubs/cacti.stubs.php b/phpstan/stubs/cacti.stubs.php index 07b0ee1..cc7c8bd 100644 --- a/phpstan/stubs/cacti.stubs.php +++ b/phpstan/stubs/cacti.stubs.php @@ -88,10 +88,10 @@ // ----- Plugin / Hook API --------------------------------------------- -function api_plugin_register_hook(string $plugin, string $hook, string $function, string $file): void { +function api_plugin_register_hook(string $plugin, string $hook, string $function, string $file, int $install = 0): void { } -function api_plugin_register_realm(string $plugin, array $file, array $display, int $install = 0): void { +function api_plugin_register_realm(string $plugin, array|string $file, array|string $display, int $install = 0): void { } function api_plugin_is_enabled(string $plugin): bool { @@ -177,7 +177,7 @@ function db_index_exists(string $table, string $index, bool $log = true, mixed $ return false; } -function db_add_index(string $table, string $type, string $name, string $definition, bool $log = true, mixed $db_conn = false): void { +function db_add_index(string $table, string $type, string $name, mixed $definition, bool $log = true, mixed $db_conn = false): void { } // ----- Config / Options --------------------------------------------- @@ -192,14 +192,17 @@ function set_config_option(string $name, string $value): bool { // ----- Localization -------------------------------------------------- -function __(string $text, string $domain = ''): string { +/** + * @param mixed ...$args sprintf arguments or domain + */ +function __(string $text, mixed ...$args): string { return $text; } /** - * @param mixed ...$args + * @param mixed ...$args sprintf arguments or domain */ -function __esc(string $text, string $domain = ''): string { +function __esc(string $text, mixed ...$args): string { return $text; } @@ -238,7 +241,7 @@ function sanitize_search_string(mixed $string): string { return ''; } -function html_escape_request_var(string $name): void { +function html_escape_request_var(string $name): string { } // ----- Logging / Misc ------------------------------------------------ @@ -288,12 +291,12 @@ function html_nav_bar(string $base_url, int $max_pages, int $current_page, int $ } /** - * @param array $display_text + * @param array> $display_text */ function html_header_sort(array $display_text, string $sort_column, string $sort_direction, bool $last_column = false): void { } -function html_escape(string $string): string { +function html_escape(mixed $string): string { return ''; } @@ -303,7 +306,7 @@ function form_alternate_row(string $id, bool $light = false): void { function form_selectable_cell(string $text, int $id, string $class = '', string $title = ''): void { } -function form_selectable_ecell(string $text, int $id): void { +function form_selectable_ecell(string $text, int $id, string $class = '', string $title = ''): void { } function form_end_row(): void { diff --git a/setup.php b/setup.php index d3b2959..3c58a31 100644 --- a/setup.php +++ b/setup.php @@ -62,11 +62,13 @@ function audit_setup_realms(bool $grant_installing_user = false): void { AND file IN (?, ?)', ['audit', 'audit.php', 'audit_manage.php']); - foreach ($realm_ids as $realm) { - db_execute_prepared('REPLACE INTO user_auth_realm + if (is_array($realm_ids)) { + foreach ($realm_ids as $realm) { + db_execute_prepared('REPLACE INTO user_auth_realm (user_id, realm_id) VALUES (?, ?)', - [$admin_user, $realm['realm_id']]); + [$admin_user, $realm['realm_id']]); + } } } } @@ -79,20 +81,22 @@ function audit_remove_deprecated_realms(): void { AND file = ?', ['audit', 'audit_purge.php']); - foreach ($realms as $realm) { - $realm_id = $realm['id'] + 100; + if (is_array($realms)) { + foreach ($realms as $realm) { + $realm_id = $realm['id'] + 100; - db_execute_prepared('DELETE FROM user_auth_realm - WHERE realm_id = ?', - [$realm_id]); + db_execute_prepared('DELETE FROM user_auth_realm + WHERE realm_id = ?', + [$realm_id]); - db_execute_prepared('DELETE FROM user_auth_group_realm - WHERE realm_id = ?', - [$realm_id]); + db_execute_prepared('DELETE FROM user_auth_group_realm + WHERE realm_id = ?', + [$realm_id]); - db_execute_prepared('DELETE FROM plugin_realms - WHERE id = ?', - [$realm['id']]); + db_execute_prepared('DELETE FROM plugin_realms + WHERE id = ?', + [$realm['id']]); + } } if (cacti_sizeof($realms)) { @@ -183,12 +187,16 @@ function audit_check_upgrade(): void { [$info['version'], $info['longname'], $info['author'], $info['homepage'], $info['name']]); // hook for table replication - api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php', '1'); + api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php', 1); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php', 1); api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php', 1); } } +/** + * @param array $data + * @return array + */ function audit_replicate_out(array $data): array { $rcnn_id = $data['rcnn_id']; $class = $data['class']; @@ -408,11 +416,14 @@ function audit_upgrade_event_schema(mixed $rcnn_id = false): void { } } +/** + * @return array + */ function plugin_audit_version(): array { global $config; $info = parse_ini_file($config['base_path'] . '/plugins/audit/INFO', true); - return $info['info']; + return is_array($info) ? $info['info'] : []; } function audit_log_valid_event(): bool { @@ -728,6 +739,10 @@ function audit_config_settings(): void { } } +/** + * @param array $nav + * @return array + */ function audit_draw_navigation_text(array $nav): array { $nav['audit.php:'] = [ 'title' => __('Audit Event Log', 'audit'), From 10e2e449d7add3084e518375747a7f695f714af7 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 16:21:34 -0400 Subject: [PATCH 28/36] Add code-quality CI workflow - .github/workflows/code-quality.yml: PHP 8.1/8.2/8.3/8.4 matrix - Runs PHP-CS-Fixer (dry-run), PHPStan, Pest, and plain-PHP tests - Existing plugin-ci-workflow.yml remains unchanged Phase 5 of code quality sweep. No functionality changes. --- .github/workflows/code-quality.yml | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/code-quality.yml diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..43bc682 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,87 @@ +# +-------------------------------------------------------------------------+ +# | Copyright (C) 2004-2026 The Cacti Group | +# | | +# | This program is free software; you can redistribute it and/or | +# | modify it under the terms of the GNU General Public License | +# | as published by the Free Software Foundation; either version 2 | +# | of the License, or (at your option) any later version. | +# | | +# | This program is distributed in the hope that it will be useful, | +# | but WITHOUT ANY WARRANTY; without even the implied warranty of | +# | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | +# | GNU General Public License for more details. | +# +-------------------------------------------------------------------------+ +# | Cacti: The Complete RRDtool-based Graphing Solution | +# +-------------------------------------------------------------------------+ +# | This code is designed, written, and maintained by the Cacti Group. See | +# | about.php and/or the AUTHORS file for specific developer information. | +# +-------------------------------------------------------------------------+ +# | http://www.cacti.net/ | +# +-------------------------------------------------------------------------+ + +name: Code Quality + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + code-quality: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + + name: PHP ${{ matrix.php }} Code Quality + + steps: + - name: Checkout audit Plugin + uses: actions/checkout@v4 + + - name: Install PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: intl, mbstring, dom, xml, json, sockets + coverage: none + ini-values: "memory_limit=512M" + + - name: Check PHP version + run: php -v + + - name: Validate composer.json + run: composer validate --strict + + - name: Install Composer Dependencies + run: composer install --prefer-dist --no-progress + + - name: Check PHP Syntax + run: | + if find . -name '*.php' -not -path './vendor/*' -not -path './locales/*' -exec php -l {} \; 2>&1 | grep -iv 'no syntax errors detected'; then + echo "Syntax errors found!" + exit 1 + fi + + - name: Run PHP-CS-Fixer (dry-run) + run: vendor/bin/php-cs-fixer fix --dry-run --diff --config=./.php-cs-fixer.php --allow-unsupported-php-version=yes + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress + + - name: Run Pest Tests + run: vendor/bin/pest tests/Security --display-warnings + + - name: Run Audit Security Helper Tests + run: | + php tests/security_functions_test.php + php tests/controller_security_test.php + php tests/syslog_queue_test.php + timeout 60 php tests/syslog_functions_test.php \ No newline at end of file From 0f6d08ed9531b3744594d6a2d80a24eefb8af8ef Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 17:16:24 -0400 Subject: [PATCH 29/36] Drop PHP 8.1 support, pin symfony to ^7.0 - Bump minimum PHP to ^8.2 (pest ^2 requires PHP 8.2+) - Pin symfony components to ^7.0 (avoid PHP 8.4-only symfony 8.x) - Set composer platform.php to 8.2 for reproducible lock - Regenerate composer.lock with 8.2-compatible versions - Update CI matrix to PHP 8.2/8.3/8.4 Fixes CI composer install failure on PHP 8.1. --- .github/workflows/code-quality.yml | 2 +- composer.json | 15 +++- composer.lock | 114 +++++++++++++++-------------- 3 files changed, 72 insertions(+), 59 deletions(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 43bc682..3a1908b 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4'] name: PHP ${{ matrix.php }} Code Quality diff --git a/composer.json b/composer.json index cea3159..9c744d6 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "type": "cacti-plugin", "license": "GPL-2.0-only", "require": { - "php": "^8.1", + "php": "^8.2", "ext-json": "*", "ext-mbstring": "*", "ext-sockets": "*" @@ -13,7 +13,15 @@ "friendsofphp/php-cs-fixer": "^3.86", "pestphp/pest": "^2.0", "pestphp/pest-plugin-drift": "^2.0", - "phpstan/phpstan": "^2.2" + "phpstan/phpstan": "^2.2", + "symfony/console": "^7.0", + "symfony/finder": "^7.0", + "symfony/process": "^7.0", + "symfony/string": "^7.0", + "symfony/event-dispatcher": "^7.0", + "symfony/filesystem": "^7.0", + "symfony/options-resolver": "^7.0", + "symfony/stopwatch": "^7.0" }, "scripts": { "php-cs-fixer": "php-cs-fixer fix --dry-run --diff --config=./.php-cs-fixer.php", @@ -29,6 +37,9 @@ ], "config": { "sort-packages": true, + "platform": { + "php": "8.2" + }, "allow-plugins": { "pestphp/pest-plugin": true } diff --git a/composer.lock b/composer.lock index 1d3f33a..dcdaf05 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9747cc3f788049ed236529a77db2b739", + "content-hash": "236babf28b60935461e0a89e45cc5333", "packages": [], "packages-dev": [ { @@ -4103,25 +4103,24 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.1.1", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/security-http": "<7.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -4130,14 +4129,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/error-handler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^7.4|^8.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4165,7 +4164,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" }, "funding": [ { @@ -4185,7 +4184,7 @@ "type": "tidelift" } ], - "time": "2026-06-09T12:28:30+00:00" + "time": "2026-06-06T11:10:32+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4269,26 +4268,25 @@ }, { "name": "symfony/filesystem", - "version": "v8.1.0", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^7.4|^8.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4316,7 +4314,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.0" + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" }, "funding": [ { @@ -4336,7 +4334,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-11T16:38:44+00:00" }, { "name": "symfony/finder", @@ -4408,20 +4406,20 @@ }, { "name": "symfony/options-resolver", - "version": "v8.1.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "88f9c561f678a02d54b897014049fa839e33ff82" + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", - "reference": "88f9c561f678a02d54b897014049fa839e33ff82", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -4455,7 +4453,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" }, "funding": [ { @@ -4475,7 +4473,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5210,20 +5208,20 @@ }, { "name": "symfony/stopwatch", - "version": "v8.1.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "21c07b026905d596e8379caeb115d87aa479499d" + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", - "reference": "21c07b026905d596e8379caeb115d87aa479499d", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/service-contracts": "^2.5|^3" }, "type": "library", @@ -5252,7 +5250,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" }, "funding": [ { @@ -5272,38 +5270,39 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5342,7 +5341,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -5362,7 +5361,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -5546,11 +5545,14 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.1", + "php": "^8.2", "ext-json": "*", "ext-mbstring": "*", "ext-sockets": "*" }, "platform-dev": [], + "platform-overrides": { + "php": "8.2" + }, "plugin-api-version": "2.6.0" } From be7faeb4e5c4d70e938ddb1266eca726d0db0026 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 17:25:13 -0400 Subject: [PATCH 30/36] Restore PHP 8.1 support - Keep require php ^8.1; pest ^2.0 requires PHP 8.2 so: - composer install uses --ignore-platform-reqs (lock resolves on any PHP) - platform pinned to 8.2 for reproducible lock - symfony pinned to ^7.0 - Pest tests skipped on PHP 8.1 in CI (pest 2.x needs 8.2+) - PHP-CS-Fixer and PHPStan run on all PHP versions (8.1-8.4) - Regenerate baseline (210 errors, unchanged) Fixes CI composer install failure while maintaining PHP 8.1 support. --- .github/workflows/code-quality.yml | 5 +- composer.json | 5 +- composer.lock | 74 +----------------------------- 3 files changed, 7 insertions(+), 77 deletions(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 3a1908b..ab5a59e 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.3', '8.4'] + php: ['8.1', '8.2', '8.3', '8.4'] name: PHP ${{ matrix.php }} Code Quality @@ -61,7 +61,7 @@ jobs: run: composer validate --strict - name: Install Composer Dependencies - run: composer install --prefer-dist --no-progress + run: composer install --prefer-dist --no-progress --ignore-platform-reqs - name: Check PHP Syntax run: | @@ -77,6 +77,7 @@ jobs: run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress - name: Run Pest Tests + if: ${{ matrix.php != '8.1' }} run: vendor/bin/pest tests/Security --display-warnings - name: Run Audit Security Helper Tests diff --git a/composer.json b/composer.json index 9c744d6..c3b0c27 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "type": "cacti-plugin", "license": "GPL-2.0-only", "require": { - "php": "^8.2", + "php": "^8.1", "ext-json": "*", "ext-mbstring": "*", "ext-sockets": "*" @@ -12,7 +12,6 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.86", "pestphp/pest": "^2.0", - "pestphp/pest-plugin-drift": "^2.0", "phpstan/phpstan": "^2.2", "symfony/console": "^7.0", "symfony/finder": "^7.0", @@ -27,7 +26,7 @@ "php-cs-fixer": "php-cs-fixer fix --dry-run --diff --config=./.php-cs-fixer.php", "php-cs-fixit": "php-cs-fixer fix --config=./.php-cs-fixer.php", "phpstan": "phpstan analyse --memory-limit=512M", - "test": "pest --display-warnings" + "test": "pest" }, "authors": [ { diff --git a/composer.lock b/composer.lock index dcdaf05..66cad06 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "236babf28b60935461e0a89e45cc5333", + "content-hash": "6c09b532f263ca1e3da868f938106757", "packages": [], "packages-dev": [ { @@ -1395,76 +1395,6 @@ ], "time": "2024-01-26T09:46:42+00:00" }, - { - "name": "pestphp/pest-plugin-drift", - "version": "v2.6.1", - "source": { - "type": "git", - "url": "https://github.com/pestphp/pest-plugin-drift.git", - "reference": "ed78e637a7f7b77c9b33b02ffba40555e74001ef" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-drift/zipball/ed78e637a7f7b77c9b33b02ffba40555e74001ef", - "reference": "ed78e637a7f7b77c9b33b02ffba40555e74001ef", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^5.0.2", - "pestphp/pest": "^2.34.4", - "php": "^8.2.0", - "symfony/finder": "^7.0.0" - }, - "require-dev": { - "pestphp/pest-dev-tools": "^2.16.0" - }, - "type": "library", - "extra": { - "pest": { - "plugins": [ - "Pest\\Drift\\Plugin" - ] - } - }, - "autoload": { - "psr-4": { - "Pest\\Drift\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "The Pest Drift Plugin", - "keywords": [ - "framework", - "laravel", - "pest", - "php", - "test", - "testing", - "unit" - ], - "support": { - "issues": "https://github.com/pestphp/pest-plugin-drift/issues", - "source": "https://github.com/pestphp/pest-plugin-drift/tree/v2.6.1" - }, - "funding": [ - { - "url": "https://www.paypal.com/paypalme/enunomaduro", - "type": "custom" - }, - { - "url": "https://github.com/mandisma", - "type": "github" - }, - { - "url": "https://github.com/nunomaduro", - "type": "github" - } - ], - "time": "2024-05-05T07:36:43+00:00" - }, { "name": "phar-io/manifest", "version": "2.0.4", @@ -5545,7 +5475,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2", + "php": "^8.1", "ext-json": "*", "ext-mbstring": "*", "ext-sockets": "*" From d58faa1adfc09f5e803588f68ba33b1fe7b5ef27 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:10:02 -0400 Subject: [PATCH 31/36] feat: add authentication and session auditing - ingest Cacti user_log events with transactional deduplication - capture logout completion and authorization-denied events - add brute-force detection with atomic alert throttling - preserve deduplication state across audit-log purges - add authentication settings and upgrade handling - test Cacti 1.2.x and develop compatibility in CI - add behavioral coverage for races, retries, paging, and retention --- .github/workflows/code-quality.yml | 1 + .github/workflows/plugin-ci-workflow.yml | 91 +++- CHANGELOG.md | 13 + README.md | 48 +- audit_functions.php | 534 +++++++++++++++++- phpstan-baseline.neon | 48 +- setup.php | 159 ++++++ tests/auth_audit_test.php | 657 +++++++++++++++++++++++ tests/controller_security_test.php | 41 ++ 9 files changed, 1569 insertions(+), 23 deletions(-) create mode 100644 tests/auth_audit_test.php diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index ab5a59e..a71397f 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -85,4 +85,5 @@ jobs: php tests/security_functions_test.php php tests/controller_security_test.php php tests/syslog_queue_test.php + php tests/auth_audit_test.php timeout 60 php tests/syslog_functions_test.php \ No newline at end of file diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index befe032..451d674 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -40,6 +40,7 @@ jobs: matrix: php: ['8.4'] os: [ubuntu-latest] + cacti_branch: ['1.2.x', 'develop'] services: mysql: @@ -57,14 +58,14 @@ jobs: --health-timeout=5s --health-retries=3 - name: PHP ${{ matrix.php }} Integration Test on ${{ matrix.os }} + name: PHP ${{ matrix.php }} Integration Test (Cacti ${{ matrix.cacti_branch }}) on ${{ matrix.os }} steps: - name: Checkout Cacti uses: actions/checkout@v7 with: repository: Cacti/cacti - ref: 1.2.x + ref: ${{ matrix.cacti_branch }} path: cacti - name: Checkout audit Plugin @@ -195,6 +196,39 @@ jobs: echo "Audit Syslog delivery queue table is missing" exit 1 fi + + AUTH_STATE_TABLE_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'cacti' + AND table_name = 'audit_user_log_state'; + ") + if [ "$AUTH_STATE_TABLE_COUNT" -ne 1 ]; then + echo "Authentication deduplication state table is missing" + exit 1 + fi + + AUTH_STATE_FK_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE constraint_schema = 'cacti' + AND table_name = 'audit_user_log_state' + AND constraint_type = 'FOREIGN KEY'; + ") + if [ "$AUTH_STATE_FK_COUNT" -ne 0 ]; then + echo "Authentication deduplication state must survive audit-log purges" + exit 1 + fi + + THROTTLE_SETTING_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM settings + WHERE name = 'audit_brute_force_last_alert'; + ") + if [ "$THROTTLE_SETTING_COUNT" -ne 1 ]; then + echo "Brute-force throttle setting was not initialized" + exit 1 + fi - name: Check PHP Syntax for Plugin run: | @@ -211,6 +245,7 @@ jobs: php tests/controller_security_test.php php tests/syslog_functions_test.php php tests/syslog_queue_test.php + php tests/auth_audit_test.php - name: Run Cacti Poller run: | @@ -241,6 +276,58 @@ jobs: cd ${{ github.workspace }}/cacti sudo php cli/add_device.php --description=test --ip=1 + - name: Exercise authentication ingestion + run: | + mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -e " + INSERT INTO user_log (username, user_id, time, result, ip) + VALUES ('audit_ci_login', 0, NOW(), 0, '192.0.2.10') + ON DUPLICATE KEY UPDATE result = VALUES(result), ip = VALUES(ip); + " + cd ${{ github.workspace }}/cacti + sudo php poller.php --poller=1 --force --debug + + AUTH_EVENT_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM audit_log + WHERE event_type = 'cacti.auth.login.failed' + AND target_id = 'audit_ci_login'; + ") + AUTH_STATE_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM audit_user_log_state + WHERE source_hash = SHA2( + CONCAT('audit_ci_login', '|', 0, '|', ( + SELECT time FROM user_log + WHERE username = 'audit_ci_login' AND user_id = 0 + ORDER BY time DESC LIMIT 1 + )), + 256 + ); + ") + + if [ "$AUTH_EVENT_COUNT" -ne 1 ] || [ "$AUTH_STATE_COUNT" -ne 1 ]; then + echo "Authentication ingestion did not atomically create one event and one state marker" + exit 1 + fi + + mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -e " + DELETE FROM audit_log + WHERE event_type = 'cacti.auth.login.failed' + AND target_id = 'audit_ci_login'; + " + sudo php poller.php --poller=1 --force --debug + + REPLAYED_EVENT_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM audit_log + WHERE event_type = 'cacti.auth.login.failed' + AND target_id = 'audit_ci_login'; + ") + if [ "$REPLAYED_EVENT_COUNT" -ne 0 ]; then + echo "Authentication source row was replayed after audit-log deletion" + exit 1 + fi + - name: check audit log entries run: | cd ${{ github.workspace }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d56164f..93d7823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ --- develop --- +* feature: Capture login failure, token, credentials-accepted, and authorization-denied events by polling the Cacti user_log table across all authentication methods +* feature: Ingest user_log every poller cycle with bounded anti-join paging and transactionally durable database-backed deduplication via audit_user_log_state +* feature: Apply the audit retention cutoff to every ingestion batch so historical rows are not replayed +* feature: Detect brute-force login patterns every poller cycle with atomically throttled critical alerts across concurrent pollers +* feature: Capture authorization-denied events through Cacti's custom_denied hook without taking over the denied-page rendering, with referer query strings redacted +* feature: Confirm session teardown through the logout_post_session_destroy hook, correlated with the existing pre-destroy logout event +* security: Record user_log result=1 as credentials_accepted with unknown outcome, not a confirmed login success +* security: Record ambiguous user_log result=3/user_id=0 and unsupported result codes as unknown rather than misclassifying them +* security: Restrict authentication auditing and brute-force detection settings to Audit Log Admin users and enforce authorization on save +* security: Gate the original logout event behind the authentication auditing master switch +* security: Persist authentication defaults on install and upgrade without overwriting existing administrator choices +* issue: Test integration CI against both Cacti 1.2.x and develop branches + * feature: Add standards-based remote Syslog delivery over UDP, TCP, and verified TLS * feature: Add RFC 5424 headers with RFC 5424, CEF, or compact JSON message formats * feature: Queue remote delivery in the poller with exponential backoff, dead-letter handling, health reporting, and audited admin actions diff --git a/README.md b/README.md index cbffe64..be2003f 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,52 @@ request. Matching state is recorded as `success` with outcome reason The plugin also audits access to its own event list, searches, event details, exports and purge operations. Logout and session-timeout events are captured -through Cacti's supported `logout_pre_session_destroy` hook. Database-level -changes, API activity and MFA events are outside the current Cacti 1.2.x scope. +through Cacti's supported `logout_pre_session_destroy` hook, and session +teardown is confirmed through the `logout_post_session_destroy` hook. + +Login failure, token (remember-me and 2FA), credentials-accepted, and +authorization-denied events are captured by polling Cacti's `user_log` table +from the poller and through the `custom_denied` hook. The `user_log` table is +the authoritative source across all Cacti authentication methods (local, LDAP, +basic, and domains) and is stable across the 1.2.x and develop branches, so +the plugin does not rely on the local-auth-only `login_process` hook. + +Cacti writes `user_log` `result = 1` before verifying that the account is +enabled, authorized for any realm, or has completed 2FA. The plugin therefore +records this as `cacti.auth.login.credentials_accepted` with +`operation_outcome = unknown`, not as a confirmed successful login. Cacti's +password-change inserts and the develop branch's failed-2FA inserts both write +`result = 3` with `user_id = 0`, so `user_log` alone cannot disambiguate them; +the plugin records these as `cacti.auth.password_change_or_2fa_failed` with +`operation_outcome = unknown`. Unsupported result codes are recorded as +`cacti.auth.login.unknown` with `operation_outcome = unknown`. + +Ingestion runs every poller cycle with a bounded workload (default 1000 rows +per cycle, configurable via `audit_user_log_batch_size`). Deduplication is +durable and database-backed: each processed `user_log` primary-key tuple is +recorded in an `audit_user_log_state` table as a deterministic SHA-256 hash, +so repeated and concurrent pollers cannot double-record the same source row. +Each bounded query selects recent source rows without a durable marker, so +failed inserts and late commits remain discoverable. The audit row and state +marker are committed in one transaction before external delivery; a +concurrent loser rolls back its duplicate audit row. State markers survive +audit-log purges and are retired only after their source rows fall outside +the configured retention window. + +Every ingestion query applies the audit retention cutoff +(`NOW() - audit_retention` days, or 90 days if retention is indefinite), so +arbitrary historical `user_log` rows are not replayed and stale records are +not exported. + +Brute-force detection runs every poller cycle and emits a +`cacti.auth.brute_force_suspected` critical event when failed logins exceed a +configurable threshold within a rolling window. Alert emission is atomically +throttled to one event per window via a conditional `UPDATE` on the settings +table, so concurrent pollers cannot emit duplicate alerts. The throttle marker +is only persisted after a confirmed audit insert. Authentication auditing and +brute-force detection settings are restricted to Audit Log Admin users. +Database-level changes and API activity remain outside the current Cacti 1.2.x +scope. ## Permissions diff --git a/audit_functions.php b/audit_functions.php index 70f070a..90268e1 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -681,7 +681,7 @@ function audit_record_event(string $event_type, array $options = []): int { $ip_address = $options['ip_address'] ?? (function_exists('get_client_addr') ? get_client_addr() : ''); $user_agent = $options['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? ''); - db_execute_prepared('INSERT INTO audit_log ( + $inserted = db_execute_prepared('INSERT INTO audit_log ( page, user_id, action, request_status, ip_address, user_agent, event_time, post, object_data, external_status, event_uuid, correlation_id, event_type, event_category, severity, actor_type, target_type, target_id, @@ -700,20 +700,46 @@ function audit_record_event(string $event_type, array $options = []): int { $options['duration_ms'] ?? 0, $details ]); - $id = db_fetch_insert_id(); + if (!$inserted) { + return 0; + } + + $id = (int) db_fetch_insert_id(); + + if ($id <= 0) { + return 0; + } + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); if (is_array($event)) { db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', [audit_event_integrity_hash($event), $id]); } - audit_deliver_external_event($id); - audit_enqueue_syslog_event($id); + + if (empty($options['defer_delivery'])) { + audit_deliver_external_event($id); + audit_enqueue_syslog_event($id); + } return $id; } function audit_logout_pre_session_destroy(): void { + // Stash the logging-out user identity and request correlation id so the + // post-destroy hook can confirm session teardown after $_SESSION is gone. + // This runs regardless of the auth-audit switch so the stash is available + // if the switch is toggled on between the two hooks (unlikely but safe). + audit_logout_stash([ + 'user_id' => (int) ($_SESSION['sess_user_id'] ?? 0), + 'correlation_id' => audit_request_correlation_id(), + 'reason' => get_nfilter_request_var('action', 'user') + ]); + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + $reason = get_nfilter_request_var('action', 'user'); $type = $reason == 'timeout' ? 'authentication.session.expired' : 'authentication.logout'; @@ -724,6 +750,464 @@ function audit_logout_pre_session_destroy(): void { ]); } +/** + * Per-request stash shared between the pre- and post-destroy logout hooks. + * + * @param array|null $set + * @return array + */ +function audit_logout_stash(?array $set = null): array { + static $stash = []; + + if (is_array($set)) { + $stash = $set; + } + + return $stash; +} + +function audit_logout_post_session_destroy(): void { + if (read_config_option('audit_enabled') != 'on') { + return; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + + $stash = audit_logout_stash(); + + if (empty($stash)) { + return; + } + + audit_record_event('authentication.logout.completed', [ + 'event_category' => 'authentication', + 'action' => 'logout_completed', + 'user_id' => $stash['user_id'] ?? 0, + 'correlation_id' => $stash['correlation_id'] ?? audit_request_correlation_id(), + 'operation_outcome' => 'success', + 'details' => [ + 'reason' => $stash['reason'] ?? 'user', + 'session_destroyed' => true + ] + ]); + + audit_logout_stash([]); +} + +/** + * Map a Cacti user_log result code to an audit event descriptor. + * + * Cacti writes user_log rows with result codes: + * 0 = Failed login + * 1 = Credentials accepted (written BEFORE enabled/realm/2FA checks) + * 2 = Success - Token (remember-me or 2FA) + * 3 = Password Change OR failed 2FA (user_id is omitted, defaulting to 0) + * + * Cacti writes result=1 before verifying that the account is enabled, + * authorized for any realm, or has completed 2FA, so it does not prove an + * authenticated session was established. It is recorded as credentials + * accepted with operation_outcome=unknown, not success. + * + * Cacti's password-change inserts and the develop branch's failed-2FA inserts + * both write result=3 with user_id=0, so user_log alone cannot disambiguate + * them; that combination is recorded as an ambiguous event with + * operation_outcome=unknown. No current Cacti path writes result=3 with + * user_id>0, but a future version may; that is treated defensively as a + * password change with outcome=unknown rather than claiming a confirmed + * password change. + * + * Any unsupported result code is recorded as an explicit unknown event rather + * than falling through to a password-change or 2FA event. + * + * @return array{event_type:string,severity:string,outcome:string,action:string,details:array} + */ +function audit_user_log_event_descriptor(int $result, int $user_id): array { + if ($result === 0) { + return [ + 'event_type' => 'cacti.auth.login.failed', + 'severity' => 'warning', + 'outcome' => 'failure', + 'action' => 'login_failed', + 'details' => [] + ]; + } + + if ($result === 1) { + return [ + 'event_type' => 'cacti.auth.login.credentials_accepted', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'credentials_accepted', + 'details' => [ + 'note' => __('Cacti records this outcome before verifying account enabled, realm authorization, or 2FA completion; a session may not have been established.', 'audit') + ] + ]; + } + + if ($result === 2) { + return [ + 'event_type' => 'cacti.auth.login.token', + 'severity' => 'info', + 'outcome' => 'success', + 'action' => 'login_token', + 'details' => [] + ]; + } + + if ($result === 3 && $user_id > 0) { + return [ + 'event_type' => 'cacti.auth.password.changed', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'password_changed', + 'details' => [ + 'note' => __('No current Cacti path writes this signature; recorded defensively as a possible password change with an unconfirmed outcome.', 'audit') + ] + ]; + } + + if ($result === 3) { + return [ + 'event_type' => 'cacti.auth.password_change_or_2fa_failed', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'password_change_or_2fa_failed', + 'details' => [ + 'ambiguous' => true, + 'note' => __('Cacti user_log result=3 with user_id=0 may be a password change or a failed 2FA challenge; the table cannot disambiguate.', 'audit') + ] + ]; + } + + // Unsupported result code: record explicitly as unknown rather than + // falling through to a password-change or 2FA event. + return [ + 'event_type' => 'cacti.auth.login.unknown', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'unknown_result', + 'details' => [ + 'unsupported_result_code' => $result + ] + ]; +} + +/** + * Compute a deterministic SHA-256 source identity for a user_log row. + */ +function audit_user_log_source_hash(string $username, int $user_id, string $time): string { + return hash('sha256', $username . '|' . $user_id . '|' . $time); +} + +/** + * Poll Cacti's user_log table for new login/logout/token/password-change + * outcomes and record them as audit events. The user_log table is the + * authoritative source across all auth methods (local, LDAP, basic, domains) + * and is stable across the 1.2.x and develop branches, so this avoids + * relying on the local-auth-only login_process hook. + * + * Deduplication is durable and database-backed: each processed user_log + * primary-key tuple (username, user_id, time) is recorded in + * audit_user_log_state as a deterministic SHA-256 hash. The audit event and + * state marker are committed atomically; a concurrent loser rolls back its + * duplicate event before any external delivery occurs. + * + * Each cycle selects a bounded batch of recent user_log rows that have no + * state marker. This anti-join approach keeps failed inserts and late commits + * discoverable instead of advancing a high-water cursor past them. The + * retention cutoff prevents arbitrary historical backfill. + */ +function audit_poll_user_log(): void { + if (read_config_option('audit_enabled') != 'on') { + return; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + + if (!function_exists('db_table_exists') || !db_table_exists('user_log')) { + return; + } + + if (!db_table_exists('audit_user_log_state')) { + return; + } + + $batch_size = (int) read_config_option('audit_user_log_batch_size'); + + if ($batch_size < 1) { + $batch_size = 1000; + } elseif ($batch_size > 5000) { + $batch_size = 5000; + } + + $retention = (int) read_config_option('audit_retention'); + + if ($retention <= 0) { + $retention = 90; + } + + $cutoff = audit_retention_cutoff($retention)->format('Y-m-d H:i:s'); + $rows = db_fetch_assoc_prepared( + 'SELECT ul.username, ul.user_id, ul.result, ul.ip, ul.time + FROM user_log AS ul + WHERE ul.time > ? + AND NOT EXISTS ( + SELECT 1 + FROM audit_user_log_state AS auls + WHERE auls.source_hash = SHA2( + CONCAT(ul.username, "|", ul.user_id, "|", ul.time), + 256 + ) + ) + ORDER BY ul.time ASC, ul.username ASC, ul.user_id ASC + LIMIT ?', + [$cutoff, $batch_size] + ); + + if (!is_array($rows) || cacti_sizeof($rows) === 0) { + return; + } + + $now_utc = audit_utc_time(); + + foreach ($rows as $row) { + $result = (int) $row['result']; + $user_id = (int) $row['user_id']; + $time = (string) $row['time']; + $username = (string) $row['username']; + + $source_hash = audit_user_log_source_hash($username, $user_id, $time); + $source_key = $username . '|' . $user_id . '|' . $time; + + if (!db_execute_prepared('START TRANSACTION')) { + continue; + } + + $descriptor = audit_user_log_event_descriptor($result, $user_id); + + $audit_id = audit_record_event($descriptor['event_type'], [ + 'event_category' => 'authentication', + 'action' => $descriptor['action'], + 'severity' => $descriptor['severity'], + 'operation_outcome' => $descriptor['outcome'], + 'actor_type' => $user_id > 0 ? 'user' : 'anonymous', + 'target_type' => 'user_account', + 'target_id' => $user_id > 0 ? (string) $user_id : $username, + 'ip_address' => (string) ($row['ip'] ?? ''), + 'user_agent' => '', + 'page' => 'user_log.php', + 'event_time' => $time, + 'defer_delivery' => true, + 'details' => [ + 'username' => $username, + 'result_code' => $result, + 'source_table' => 'user_log', + 'descriptor' => $descriptor['details'] + ] + ]); + + if ($audit_id <= 0) { + db_execute_prepared('ROLLBACK'); + + continue; + } + + $state_inserted = db_execute_prepared( + 'INSERT IGNORE INTO audit_user_log_state + (source_hash, source_key, source_time, audit_id, processed_time) + VALUES (?, ?, ?, ?, ?)', + [$source_hash, $source_key, $time, $audit_id, $now_utc] + ); + + if (!$state_inserted || db_affected_rows() !== 1) { + db_execute_prepared('ROLLBACK'); + + continue; + } + + if (!db_execute_prepared('COMMIT')) { + db_execute_prepared('ROLLBACK'); + + continue; + } + + audit_deliver_external_event($audit_id); + audit_enqueue_syslog_event($audit_id); + } +} + +/** + * Detect brute-force login patterns by counting failed user_log entries + * within a rolling window. Emits a single critical audit event per window + * to avoid alert flooding. + */ +function audit_detect_brute_force(): void { + if (read_config_option('audit_enabled') != 'on') { + return; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + + if (read_config_option('audit_brute_force_enabled') != 'on') { + return; + } + + if (!function_exists('db_table_exists') || !db_table_exists('user_log')) { + return; + } + + $window = (int) read_config_option('audit_brute_force_window_minutes'); + + if ($window < 1) { + $window = 5; + } elseif ($window > 1440) { + $window = 1440; + } + + $threshold = (int) read_config_option('audit_brute_force_threshold'); + + if ($threshold < 1) { + $threshold = 10; + } elseif ($threshold > 1000) { + $threshold = 1000; + } + + $count = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) + FROM user_log + WHERE result = 0 + AND time >= DATE_SUB(NOW(), INTERVAL ? MINUTE)', + [$window] + ); + + if ($count < $threshold) { + return; + } + + // Atomically claim the alert slot so two concurrent pollers cannot both + // emit for the same window. The conditional UPDATE only succeeds if the + // last alert is empty or older than the window; the affected-row count + // proves ownership. The settings row is created defensively first so a + // fresh install can participate in the same atomic claim. + $now = gmdate('Y-m-d H:i:s'); + + $initialized = db_execute_prepared( + 'INSERT IGNORE INTO settings (name, value) VALUES (?, ?)', + ['audit_brute_force_last_alert', ''] + ); + + if (!$initialized) { + return; + } + + $claimed = db_execute_prepared( + "UPDATE settings + SET value = ? + WHERE name = 'audit_brute_force_last_alert' + AND (value = '' OR value = '0' + OR STR_TO_DATE(value, '%Y-%m-%d %H:%i:%s') < DATE_SUB(?, INTERVAL ? MINUTE))", + [$now, $now, $window] + ); + + if (!$claimed || db_affected_rows() < 1) { + return; + } + + $audit_id = audit_record_event('cacti.auth.brute_force_suspected', [ + 'event_category' => 'authentication', + 'action' => 'brute_force_suspected', + 'severity' => 'critical', + 'operation_outcome' => 'failure', + 'actor_type' => 'system', + 'target_type' => 'authentication', + 'details' => [ + 'failed_attempts' => $count, + 'window_minutes' => $window, + 'threshold' => $threshold + ] + ]); + + // Keep the claimed timestamp after a confirmed successful audit insert. + // If the insert failed, release the slot so the next poller can retry. + if ($audit_id > 0) { + set_config_option('audit_brute_force_last_alert', $now); + } else { + set_config_option('audit_brute_force_last_alert', ''); + } +} + +/** + * Hook handler for Cacti's custom_denied hook. Records an authorization- + * denied event and returns the input mode unchanged so Cacti continues + * rendering its default permission-denied page. + * + * @param mixed $mode + * @return mixed + */ +function audit_custom_denied(mixed $mode): mixed { + if (read_config_option('audit_enabled') != 'on') { + return $mode; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return $mode; + } + + $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + $user_id = (int) ($_SESSION['sess_user_id'] ?? 0); + + // Record only the referer origin and path; strip the query string to + // avoid leaking tokens, reset hashes, OAuth state, or session + // identifiers into the audit log and external syslog consumers. + $safe_referer = ''; + + if ($referer !== '') { + $parsed = parse_url((string) $referer); + $safe_ref = ''; + + if (is_array($parsed)) { + if (isset($parsed['scheme']) && isset($parsed['host'])) { + $safe_ref = $parsed['scheme'] . '://' . $parsed['host']; + + if (isset($parsed['port'])) { + $safe_ref .= ':' . $parsed['port']; + } + } + + if (isset($parsed['path'])) { + $safe_ref .= $parsed['path']; + } + } + + $safe_referer = $safe_ref !== '' ? $safe_ref : '[unparseable]'; + } + + audit_record_event('cacti.auth.authorization.denied', [ + 'event_category' => 'authentication', + 'action' => 'authorization_denied', + 'severity' => 'warning', + 'operation_outcome' => 'failure', + 'actor_type' => $user_id > 0 ? 'user' : 'anonymous', + 'target_type' => 'page', + 'target_id' => $page, + 'page' => $page, + 'details' => [ + 'requested_page' => $page, + 'referer_origin' => $safe_referer, + 'referer_redacted' => $referer !== $safe_referer + ] + ]); + + return $mode; +} + function audit_enforce_syslog_settings_request(): void { $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); $method = $_SERVER['REQUEST_METHOD'] ?? ''; @@ -740,28 +1224,46 @@ function audit_enforce_syslog_settings_request(): void { } $has_syslog_fields = false; + $has_auth_fields = false; foreach ($post as $name => $value) { - if (strpos((string) $name, 'audit_syslog_') === 0) { - $has_syslog_fields = true; + $name = (string) $name; - break; + if (strpos($name, 'audit_syslog_') === 0) { + $has_syslog_fields = true; + } elseif (strpos($name, 'audit_auth_') === 0 || strpos($name, 'audit_brute_force_') === 0) { + $has_auth_fields = true; } } - if (!$has_syslog_fields) { + if (!$has_syslog_fields && !$has_auth_fields) { return; } if (!audit_user_is_admin()) { - audit_record_event('audit.syslog.configuration.denied', [ - 'event_category' => 'audit', - 'severity' => 'warning', - 'action' => 'save', - 'target_type' => 'syslog_configuration', - 'operation_outcome' => 'failure', - 'outcome_reason' => 'audit_admin_required' - ]); + // Preserve the syslog-specific denied event when syslog fields are + // part of the unauthorized save; use a generic audit-configuration + // event when only authentication/brute-force fields are present. + if ($has_syslog_fields) { + audit_record_event('audit.syslog.configuration.denied', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'save', + 'target_type' => 'syslog_configuration', + 'operation_outcome' => 'failure', + 'outcome_reason' => 'audit_admin_required' + ]); + } else { + audit_record_event('audit.configuration.denied', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'save', + 'target_type' => 'audit_configuration', + 'operation_outcome' => 'failure', + 'outcome_reason' => 'audit_admin_required' + ]); + } + http_response_code(403); exit; } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 782fd76..2170645 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -15,7 +15,7 @@ parameters: - message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' identifier: function.resultUnused - count: 1 + count: 3 path: audit_functions.php - @@ -39,7 +39,7 @@ parameters: - message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' identifier: function.resultUnused - count: 1 + count: 2 path: setup.php - @@ -90,6 +90,48 @@ parameters: count: 1 path: tests/Security/SetupStructureTest.php + - + message: '#^Call to function in_array\(\) with arguments ''old'', array\{\} and true will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: tests/auth_audit_test.php + + - + message: '#^Empty array passed to foreach\.$#' + identifier: foreach.emptyArray + count: 1 + path: tests/auth_audit_test.php + + - + message: '#^Offset ''audit_brute_force…'' on array\{audit_brute_force_last_alert\: ''''\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: tests/auth_audit_test.php + + - + message: '#^Offset 0 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 9 + path: tests/auth_audit_test.php + + - + message: '#^Offset 1 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 2 + path: tests/auth_audit_test.php + + - + message: '#^Offset 3 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 2 + path: tests/auth_audit_test.php + + - + message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|false given\.$#' + identifier: argument.type + count: 4 + path: tests/auth_audit_test.php + - message: '#^Function __\(\) has no return type specified\.$#' identifier: missingType.return @@ -579,7 +621,7 @@ parameters: - message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|false given\.$#' identifier: argument.type - count: 11 + count: 12 path: tests/controller_security_test.php - diff --git a/setup.php b/setup.php index 3c58a31..9d2eaae 100644 --- a/setup.php +++ b/setup.php @@ -33,6 +33,8 @@ function plugin_audit_install(): void { api_plugin_register_hook('audit', 'utilities_array', 'audit_utilities_array', 'setup.php'); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php'); api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php'); + api_plugin_register_hook('audit', 'logout_post_session_destroy', 'audit_logout_post_session_destroy', 'audit_functions.php'); + api_plugin_register_hook('audit', 'custom_denied', 'audit_custom_denied', 'audit_functions.php'); // hook for table replication api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php'); @@ -40,6 +42,35 @@ function plugin_audit_install(): void { audit_setup_realms(true); audit_setup_table(); + audit_persist_auth_defaults(); +} + +/** + * Persist authentication auditing defaults without overwriting existing + * administrator choices. Called on fresh install and upgrade so that + * ordinary-user logout and authorization-denied hooks work with the + * advertised default even though the configuration controls remain hidden + * from non-Audit-Admin users. + */ +function audit_persist_auth_defaults(): void { + $defaults = [ + 'audit_auth_log_enabled' => 'on', + 'audit_brute_force_enabled' => 'on', + 'audit_brute_force_window_minutes' => '5', + 'audit_brute_force_threshold' => '10', + 'audit_brute_force_last_alert' => '' + ]; + + foreach ($defaults as $name => $value) { + $exists = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) FROM settings WHERE name = ?', + [$name] + ); + + if ($exists === 0) { + set_config_option($name, $value); + } + } } function audit_setup_realms(bool $grant_installing_user = false): void { @@ -105,6 +136,7 @@ function audit_remove_deprecated_realms(): void { } function plugin_audit_uninstall(): bool { + db_execute('DROP TABLE IF EXISTS audit_user_log_state'); db_execute('DROP TABLE IF EXISTS audit_syslog_delivery'); db_execute('DROP TABLE IF EXISTS audit_log'); @@ -170,6 +202,8 @@ function audit_check_upgrade(): void { db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status'); audit_upgrade_event_schema(); audit_setup_syslog_table(); + audit_setup_user_log_state_table(); + audit_persist_auth_defaults(); audit_setup_realms(); audit_remove_deprecated_realms(); @@ -190,6 +224,8 @@ function audit_check_upgrade(): void { api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php', 1); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php', 1); api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php', 1); + api_plugin_register_hook('audit', 'logout_post_session_destroy', 'audit_logout_post_session_destroy', 'audit_functions.php', 1); + api_plugin_register_hook('audit', 'custom_denied', 'audit_custom_denied', 'audit_functions.php', 1); } } @@ -242,6 +278,9 @@ function audit_replicate_out(array $data): array { db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data", true, $rcnn_id); db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status', true, $rcnn_id); audit_upgrade_event_schema($rcnn_id); + + // Replicate and migrate durable user_log deduplication state. + audit_setup_user_log_state_table($rcnn_id); } return $data; @@ -251,6 +290,19 @@ function audit_poller_bottom(): void { audit_retry_external_logs(); audit_process_syslog_queue(); + // Brute-force detection runs every poller cycle so short bursts are + // caught in near-real-time. Only alert emission is throttled inside the + // function via audit_brute_force_last_alert. + audit_detect_brute_force(); + + // Authentication events are captured by polling Cacti's user_log table, + // which is authoritative across all auth methods (local, LDAP, basic, + // domains) and stable across the 1.2.x and develop branches. Ingestion + // runs every poller cycle with a bounded workload so login failures and + // authorization events appear promptly; the deduplication table prevents + // duplicate events across repeated and concurrent pollers. + audit_poll_user_log(); + $last_check = read_config_option('audit_last_check'); $now = gmdate('Y-m-d'); @@ -271,6 +323,16 @@ function audit_poller_bottom(): void { [$cutoff->format('Y-m-d H:i:s')]); $rows = db_affected_rows(); cacti_log('NOTE: Purged ' . $rows . ' Audit Log Records from Cacti', false, 'POLLER'); + + // Deduplication state intentionally survives audit_log deletion so + // recent user_log rows are not imported again. Markers older than + // this cutoff can be removed safely because polling never selects + // source rows outside the same retention window. + if (db_table_exists('audit_user_log_state')) { + db_execute_prepared('DELETE FROM audit_user_log_state + WHERE source_time < ?', + [$cutoff->format('Y-m-d H:i:s')]); + } } } @@ -329,10 +391,64 @@ function audit_setup_table(): bool { COMMENT='Audit Log for all GUI activities'"); audit_setup_syslog_table(); + audit_setup_user_log_state_table(); return true; } +/** + * Durable, database-backed deduplication table for user_log ingestion. + * + * Each processed user_log primary-key tuple (username, user_id, time) is + * recorded as a deterministic SHA-256 hash so repeated and concurrent + * pollers cannot double-record the same source row. audit_id is deliberately + * not a foreign key: deduplication state must survive audit-log retention and + * manual purges, otherwise recent user_log rows would be imported again. + */ +function audit_setup_user_log_state_table(mixed $cnn_id = false): void { + db_execute("CREATE TABLE IF NOT EXISTS `audit_user_log_state` ( + `source_hash` char(64) NOT NULL, + `source_key` varchar(160) NOT NULL DEFAULT '', + `source_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `audit_id` bigint(20) unsigned NOT NULL, + `processed_time` datetime(6) NOT NULL, + PRIMARY KEY (`source_hash`), + KEY `source_time_key` (`source_time`, `source_key`)) + ENGINE=InnoDB + COMMENT='Durable deduplication state for user_log ingestion'", + true, + $cnn_id + ); + + $has_foreign_key = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ? + AND CONSTRAINT_TYPE = ?', + ['audit_user_log_state', 'fk_audit_user_log_state_event', 'FOREIGN KEY'], + '', + true, + $cnn_id + ); + + if ($has_foreign_key > 0) { + db_execute( + 'ALTER TABLE audit_user_log_state + DROP FOREIGN KEY fk_audit_user_log_state_event', + true, + $cnn_id + ); + } + + db_execute('ALTER TABLE audit_user_log_state + ADD COLUMN IF NOT EXISTS source_key varchar(160) NOT NULL DEFAULT "" AFTER source_hash', + true, + $cnn_id + ); +} + function audit_setup_syslog_table(): void { db_execute("CREATE TABLE IF NOT EXISTS `audit_syslog_delivery` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, @@ -554,6 +670,49 @@ function audit_config_settings(): void { ]; if (php_sapi_name() === 'cli' || audit_user_is_admin()) { + $auth_settings = [ + 'audit_auth_header' => [ + 'friendly_name' => __('Authentication Auditing', 'audit'), + 'method' => 'spacer', + ], + 'audit_auth_log_enabled' => [ + 'friendly_name' => __('Enable Authentication Auditing', 'audit'), + 'description' => __('Check this box to capture login, logout, token, password-change, and authorization-denied events by polling the Cacti user_log table and supported hooks.', 'audit'), + 'method' => 'checkbox', + 'default' => 'on' + ], + 'audit_brute_force_enabled' => [ + 'friendly_name' => __('Enable Brute-force Detection', 'audit'), + 'description' => __('Check this box to emit a critical audit event when failed logins exceed the threshold within the configured window.', 'audit'), + 'method' => 'checkbox', + 'default' => 'on' + ], + 'audit_brute_force_window_minutes' => [ + 'friendly_name' => __('Brute-force Window (minutes)', 'audit'), + 'description' => __('Rolling window in minutes, from 1 through 1440, used to count failed logins.', 'audit'), + 'method' => 'textbox', + 'default' => '5', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_brute_force_threshold' => [ + 'friendly_name' => __('Brute-force Threshold', 'audit'), + 'description' => __('Number of failed logins within the window, from 1 through 1000, that triggers a brute-force alert.', 'audit'), + 'method' => 'textbox', + 'default' => '10', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_user_log_batch_size' => [ + 'friendly_name' => __('User Log Ingestion Batch Size', 'audit'), + 'description' => __('Maximum user_log rows ingested per poller cycle, from 1 through 5000. Larger batches process backlogs faster but increase poller runtime.', 'audit'), + 'method' => 'textbox', + 'default' => '1000', + 'max_length' => '4', + 'size' => '8' + ], + ]; + $facility_options = []; foreach (audit_syslog_facilities() as $facility => $code) { diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php new file mode 100644 index 0000000..f043aa5 --- /dev/null +++ b/tests/auth_audit_test.php @@ -0,0 +1,657 @@ + 'on', + 'audit_auth_log_enabled' => 'on', + 'audit_brute_force_enabled' => 'on', + 'audit_brute_force_window_minutes' => '5', + 'audit_brute_force_threshold' => '10', + 'audit_brute_force_last_alert' => '', + 'audit_user_log_batch_size' => '1000', + 'audit_retention' => '90' +]; + +$audit_auth_recorded_events = []; +$audit_auth_user_log_rows = []; +$audit_auth_state_rows = []; +$audit_auth_failed_count = 0; +$audit_auth_set_options = []; +$audit_auth_settings_rows = []; +$audit_auth_insert_fails = false; +$audit_auth_fail_usernames = []; +$audit_auth_state_conflict = false; +$audit_auth_affected_rows = 0; +$audit_auth_transaction = null; + +function read_config_option(string $name, bool $force = false): string { + global $audit_auth_config, $audit_auth_settings_rows; + + if (isset($audit_auth_settings_rows[$name])) { + return (string) $audit_auth_settings_rows[$name]; + } + + return $audit_auth_config[$name] ?? ''; +} + +function set_config_option(string $name, string $value): void { + global $audit_auth_set_options, $audit_auth_config, $audit_auth_settings_rows; + $audit_auth_config[$name] = $value; + $audit_auth_settings_rows[$name] = $value; + $audit_auth_set_options[$name][] = $value; +} + +function db_execute_prepared(string $sql, array $params = []): bool { + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction; + + if (strpos($sql, 'START TRANSACTION') !== false) { + $audit_auth_transaction = [ + 'events' => $audit_auth_recorded_events, + 'state' => $audit_auth_state_rows + ]; + + return true; + } + + if (strpos($sql, 'ROLLBACK') !== false) { + if (is_array($audit_auth_transaction)) { + $audit_auth_recorded_events = $audit_auth_transaction['events']; + $audit_auth_state_rows = $audit_auth_transaction['state']; + } + + $audit_auth_transaction = null; + + return true; + } + + if (strpos($sql, 'COMMIT') !== false) { + $audit_auth_transaction = null; + + return true; + } + + if (strpos($sql, 'INSERT INTO audit_log') !== false) { + $details = json_decode((string) ($params[24] ?? ''), true); + $username = is_array($details) ? (string) ($details['username'] ?? '') : ''; + + if ($audit_auth_insert_fails || in_array($username, $audit_auth_fail_usernames, true)) { + $audit_auth_affected_rows = 0; + + return false; + } + $audit_auth_recorded_events[] = ['sql' => $sql, 'params' => $params]; + $audit_auth_affected_rows = 1; + + return true; + } + + if (strpos($sql, 'INSERT IGNORE INTO audit_user_log_state') !== false) { + $hash = $params[0]; + + if ($audit_auth_state_conflict || isset($audit_auth_state_rows[$hash])) { + $audit_auth_affected_rows = 0; + } else { + $audit_auth_state_rows[$hash] = [ + 'source_hash' => $hash, + 'source_key' => $params[1], + 'source_time' => $params[2], + 'audit_id' => $params[3], + 'processed_time' => $params[4] + ]; + $audit_auth_affected_rows = 1; + } + + return true; + } + + if (strpos($sql, 'INSERT IGNORE INTO settings') !== false) { + $name = (string) $params[0]; + + if (!array_key_exists($name, $audit_auth_settings_rows)) { + $audit_auth_settings_rows[$name] = (string) $params[1]; + $audit_auth_affected_rows = 1; + } else { + $audit_auth_affected_rows = 0; + } + + return true; + } + + if (strpos($sql, 'UPDATE settings') !== false && strpos($sql, 'audit_brute_force_last_alert') !== false) { + $name = 'audit_brute_force_last_alert'; + $current = $audit_auth_settings_rows[$name] ?? ''; + $now = $params[0]; + $window = $params[2]; + $should_update = ($current === '' || $current === '0'); + + if (!$should_update && $current !== '') { + $ts = strtotime($current); + $now_ts = strtotime($now); + $should_update = ($ts !== false && $now_ts !== false && ($now_ts - $ts) >= ($window * 60)); + } + + if ($should_update) { + $audit_auth_settings_rows[$name] = $now; + $audit_auth_affected_rows = 1; + + return true; + } + + $audit_auth_affected_rows = 0; + + return true; + } + + if (strpos($sql, 'DELETE FROM audit_user_log_state') !== false) { + $audit_auth_state_rows = []; + + return true; + } + + return true; +} + +function db_fetch_insert_id(): int { + global $audit_auth_recorded_events, $audit_auth_insert_fails; + + if ($audit_auth_insert_fails) { + return 0; + } + + return count($audit_auth_recorded_events); +} + +function db_fetch_row_prepared(string $sql, array $params = []): array { + global $audit_auth_state_rows; + + if (strpos($sql, 'MAX(source_time)') !== false) { + if (empty($audit_auth_state_rows)) { + return []; + } + + $max_time = ''; + $max_hash = ''; + + foreach ($audit_auth_state_rows as $row) { + if ($row['source_time'] > $max_time || ($row['source_time'] === $max_time && ($row['source_key'] ?? '') > $max_hash)) { + $max_time = $row['source_time']; + $max_hash = $row['source_key'] ?? ''; + } + } + + return ['max_time' => $max_time, 'max_key' => $max_hash]; + } + + return []; +} + +/** + * SQL-interpreting stub: filters user_log rows by retention and durable + * deduplication state, orders by (time, username, user_id), and applies LIMIT. + */ +function db_fetch_assoc_prepared(string $sql, array $params = []): array { + global $audit_auth_user_log_rows, $audit_auth_state_rows; + + if (strpos($sql, 'FROM user_log') === false) { + return []; + } + + $cutoff = (string) ($params[0] ?? ''); + $filtered = []; + + foreach ($audit_auth_user_log_rows as $row) { + $time = (string) $row['time']; + $hash = audit_user_log_source_hash( + (string) $row['username'], + (int) $row['user_id'], + $time + ); + + if ($time > $cutoff && !isset($audit_auth_state_rows[$hash])) { + $filtered[] = $row; + } + } + + usort($filtered, function ($a, $b) { + if ($a['time'] !== $b['time']) { + return $a['time'] <=> $b['time']; + } + + if ($a['username'] !== $b['username']) { + return $a['username'] <=> $b['username']; + } + + return $a['user_id'] <=> $b['user_id']; + }); + + $limit_idx = count($params) - 1; + $limit = (int) ($params[$limit_idx] ?? 1000); + + return array_slice($filtered, 0, $limit); +} + +function db_fetch_cell_prepared(string $sql, array $params = []): int|string { + global $audit_auth_failed_count, $audit_auth_state_rows; + + if (strpos($sql, 'COUNT(*)') !== false && strpos($sql, 'result = 0') !== false) { + return $audit_auth_failed_count; + } + + if (strpos($sql, 'SELECT 1 FROM audit_user_log_state') !== false) { + $hash = $params[0] ?? ''; + + return isset($audit_auth_state_rows[$hash]) ? 1 : 0; + } + + return ''; +} + +function db_affected_rows(): int { + global $audit_auth_affected_rows; + + return $audit_auth_affected_rows; +} + +function db_table_exists(string $table): bool { + return in_array($table, ['user_log', 'audit_user_log_state'], true); +} + +function cacti_sizeof(array|bool $array): int { + return is_array($array) ? count($array) : 0; +} + +function get_nfilter_request_var(string $name, mixed $default = null): mixed { + return $_REQUEST[$name] ?? $default; +} + +function get_request_var(string $name): mixed { + return $_REQUEST[$name] ?? ''; +} + +function api_plugin_user_realm_auth(string $filename = ''): bool { + return false; +} + +function html_escape(mixed $string): string { + return htmlspecialchars((string) $string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); +} + +function __(string $text, string $domain = ''): string { + return $text; +} + +function cacti_log(string $message, bool $also_print = false, string $log_type = '', int $level = 0): void { +} + +function audit_test_assert_same(mixed $expected, mixed $actual, string $message): void { + if ($expected !== $actual) { + fwrite(STDERR, $message . PHP_EOL); + fwrite(STDERR, 'Expected: ' . var_export($expected, true) . PHP_EOL); + fwrite(STDERR, 'Actual: ' . var_export($actual, true) . PHP_EOL); + exit(1); + } +} + +function audit_test_assert_true(bool $condition, string $message): void { + if (!$condition) { + fwrite(STDERR, $message . PHP_EOL); + exit(1); + } +} + +function audit_test_reset_state(): void { + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_set_options, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction; + $audit_auth_recorded_events = []; + $audit_auth_state_rows = []; + $audit_auth_set_options = []; + $audit_auth_settings_rows = []; + $audit_auth_insert_fails = false; + $audit_auth_fail_usernames = []; + $audit_auth_state_conflict = false; + $audit_auth_affected_rows = 0; + $audit_auth_transaction = null; +} + +// --------------------------------------------------------------------------- +// 1. Result-code mapping (audit_user_log_event_descriptor) +// --------------------------------------------------------------------------- + +audit_test_assert_same('cacti.auth.login.failed', audit_user_log_event_descriptor(0, 0)['event_type'], 'Failed logins must map to a login failed event.'); +audit_test_assert_same('failure', audit_user_log_event_descriptor(0, 0)['outcome'], 'Failed logins must be a failure outcome.'); + +// result=1: credentials accepted, NOT success (Cacti writes before checks). +audit_test_assert_same('cacti.auth.login.credentials_accepted', audit_user_log_event_descriptor(1, 5)['event_type'], 'result=1 must be credentials_accepted, not login.success.'); +audit_test_assert_same('unknown', audit_user_log_event_descriptor(1, 5)['outcome'], 'result=1 must carry an unknown outcome, not success.'); + +audit_test_assert_same('cacti.auth.login.token', audit_user_log_event_descriptor(2, 5)['event_type'], 'Token success must map to a login token event.'); +audit_test_assert_same('success', audit_user_log_event_descriptor(2, 5)['outcome'], 'Token success must be a success outcome.'); + +// result=3/user_id>0: defensive, unknown outcome (no false success). +audit_test_assert_same('cacti.auth.password.changed', audit_user_log_event_descriptor(3, 5)['event_type'], 'result=3/user_id>0 must map to password.changed.'); +audit_test_assert_same('unknown', audit_user_log_event_descriptor(3, 5)['outcome'], 'result=3/user_id>0 must carry unknown, not a confirmed success.'); + +// result=3/user_id=0: ambiguous. +$ambiguous = audit_user_log_event_descriptor(3, 0); +audit_test_assert_same('cacti.auth.password_change_or_2fa_failed', $ambiguous['event_type'], 'result=3/user_id=0 must map to the ambiguous event type.'); +audit_test_assert_same('unknown', $ambiguous['outcome'], 'The ambiguous event must carry an unknown outcome.'); +audit_test_assert_true(isset($ambiguous['details']['ambiguous']), 'The ambiguous event must flag itself as ambiguous.'); + +// Unsupported result code: explicit unknown, not a fallthrough. +$unknown = audit_user_log_event_descriptor(99, 5); +audit_test_assert_same('cacti.auth.login.unknown', $unknown['event_type'], 'Unsupported result codes must map to an explicit unknown event.'); +audit_test_assert_same('unknown', $unknown['outcome'], 'Unsupported result codes must carry an unknown outcome.'); +audit_test_assert_same(99, $unknown['details']['unsupported_result_code'], 'The unsupported code must be recorded in details.'); + +// --------------------------------------------------------------------------- +// 2. audit_poll_user_log() records one event per new user_log row +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'alice', 'user_id' => 5, 'result' => 1, 'ip' => '10.0.0.1', 'time' => '2026-07-25 10:00:01'], + ['username' => 'bob', 'user_id' => 0, 'result' => 0, 'ip' => '10.0.0.2', 'time' => '2026-07-25 10:00:02'], + ['username' => 'carol', 'user_id' => 7, 'result' => 2, 'ip' => '10.0.0.3', 'time' => '2026-07-25 10:00:03'], + ['username' => 'dave', 'user_id' => 0, 'result' => 3, 'ip' => '10.0.0.4', 'time' => '2026-07-25 10:00:04'] +]; + +audit_poll_user_log(); + +audit_test_assert_same(4, count($audit_auth_recorded_events), 'audit_poll_user_log() must record one event per new user_log row.'); +audit_test_assert_same('cacti.auth.login.credentials_accepted', $audit_auth_recorded_events[0]['params'][12], 'First event must be credentials_accepted for result=1.'); +audit_test_assert_same('warning', $audit_auth_recorded_events[1]['params'][14], 'Failed login must carry warning severity.'); +audit_test_assert_same('failure', $audit_auth_recorded_events[1]['params'][18], 'Failed login must carry failure outcome.'); +audit_test_assert_same('cacti.auth.password_change_or_2fa_failed', $audit_auth_recorded_events[3]['params'][12], 'result=3/user_id=0 must map to the ambiguous event.'); +audit_test_assert_same('unknown', $audit_auth_recorded_events[3]['params'][18], 'The ambiguous row must carry unknown outcome.'); +audit_test_assert_same(4, count($audit_auth_state_rows), 'Four deduplication state rows must be written.'); + +// Re-polling must not double-record (deduplication via state table). +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Re-polling after durable state is recorded must not produce duplicates.'); + +// Disabling auth auditing must skip polling. +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +$audit_auth_user_log_rows = [ + ['username' => 'eve', 'user_id' => 9, 'result' => 1, 'ip' => '10.0.0.5', 'time' => '2026-07-25 11:00:00'] +]; +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'audit_poll_user_log() must be gated by audit_auth_log_enabled.'); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 3. More than 1,000 rows sharing one timestamp: bounded anti-join paging +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_config['audit_user_log_batch_size'] = '1000'; +$audit_auth_user_log_rows = []; + +for ($i = 0; $i < 1500; $i++) { + $audit_auth_user_log_rows[] = [ + 'username' => sprintf('user%04d', $i), + 'user_id' => $i + 1, + 'result' => 0, + 'ip' => '10.0.0.10', + 'time' => '2026-07-25 12:00:00' + ]; +} + +audit_poll_user_log(); +audit_test_assert_same(1000, count($audit_auth_recorded_events), 'First cycle must process exactly the batch size (1000) rows, not all 1500.'); +audit_test_assert_same(1000, count($audit_auth_state_rows), '1000 state rows must be written after the first cycle.'); + +// Second cycle excludes durable markers and processes the remaining 500. +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(500, count($audit_auth_recorded_events), 'Second cycle must process the remaining 500 rows via durable-state exclusion.'); +audit_test_assert_same(1500, count($audit_auth_state_rows), 'All 1500 state rows must be written after two cycles.'); + +// Third cycle: nothing left. +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Third cycle must find no new rows.'); + +// --------------------------------------------------------------------------- +// 4. Multiple timestamp pages +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'a', 'user_id' => 1, 'result' => 1, 'ip' => '10.0.0.1', 'time' => '2026-07-25 13:00:00'], + ['username' => 'b', 'user_id' => 2, 'result' => 0, 'ip' => '10.0.0.2', 'time' => '2026-07-25 13:00:01'], + ['username' => 'c', 'user_id' => 3, 'result' => 2, 'ip' => '10.0.0.3', 'time' => '2026-07-25 13:00:02'] +]; + +audit_poll_user_log(); +audit_test_assert_same(3, count($audit_auth_recorded_events), 'All three timestamp pages must be processed in one cycle.'); +$first_details = json_decode($audit_auth_recorded_events[0]['params'][24], true); +audit_test_assert_same('a', $first_details['username'], 'Rows must be ordered by time then username.'); + +// --------------------------------------------------------------------------- +// 5. Concurrent/overlapping poller ownership: no duplicates +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'concurrent', 'user_id' => 42, 'result' => 1, 'ip' => '10.0.0.99', 'time' => '2026-07-25 14:00:00'] +]; + +// Simulate a concurrent poller that already recorded the state row. +$hash = audit_user_log_source_hash('concurrent', 42, '2026-07-25 14:00:00'); +$audit_auth_state_rows[$hash] = [ + 'source_hash' => $hash, + 'source_key' => 'concurrent|42|2026-07-25 14:00:00', + 'source_time' => '2026-07-25 14:00:00', + 'audit_id' => 999, + 'processed_time' => '2026-07-25 14:00:01' +]; + +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A row already claimed by a concurrent poller must not be double-recorded.'); + +// Simulate both pollers selecting the row before either state marker is +// visible. The losing transaction must roll back its audit event when its +// INSERT IGNORE reports that another poller won the unique source hash. +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'racing', 'user_id' => 43, 'result' => 1, 'ip' => '10.0.0.100', 'time' => '2026-07-25 14:01:00'] +]; +$audit_auth_state_conflict = true; + +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A concurrent state-insert loser must roll back its duplicate audit event.'); +audit_test_assert_same(0, count($audit_auth_state_rows), 'A concurrent state-insert loser must not create a state marker.'); + +// --------------------------------------------------------------------------- +// 6. Failed audit inserts remain discoverable behind later successful rows +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'failinsert', 'user_id' => 50, 'result' => 1, 'ip' => '10.0.0.50', 'time' => '2026-07-25 15:00:00'], + ['username' => 'later', 'user_id' => 51, 'result' => 1, 'ip' => '10.0.0.51', 'time' => '2026-07-25 15:00:01'] +]; +$audit_auth_fail_usernames = ['failinsert']; + +audit_poll_user_log(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'A later row must still be recorded when an earlier audit insert fails.'); +audit_test_assert_same(1, count($audit_auth_state_rows), 'Only the successful later row may receive a state marker.'); + +// Retry after the insert recovers: the earlier row must remain discoverable +// even though a later source time has already been processed. +$audit_auth_fail_usernames = []; +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'A failed row behind a later success must be retried on the next cycle.'); +audit_test_assert_same(2, count($audit_auth_state_rows), 'The retried row must produce its durable state marker.'); + +// --------------------------------------------------------------------------- +// 7. Retention policy excludes arbitrary historical rows +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_config['audit_retention'] = '30'; +$audit_auth_user_log_rows = [ + ['username' => 'old', 'user_id' => 1, 'result' => 1, 'ip' => '10.0.0.1', 'time' => '2026-06-01 00:00:00'], + ['username' => 'recent', 'user_id' => 2, 'result' => 1, 'ip' => '10.0.0.2', 'time' => '2026-07-24 00:00:00'] +]; + +audit_poll_user_log(); +$recorded_usernames = []; + +foreach ($audit_auth_recorded_events as $event) { + $details = json_decode($event['params'][24], true); + + if (is_array($details) && isset($details['username'])) { + $recorded_usernames[] = $details['username']; + } +} + +audit_test_assert_true(!in_array('old', $recorded_usernames, true), 'Initial ingestion must not replay rows older than the retention cutoff.'); +audit_test_reset_state(); +$audit_auth_config['audit_retention'] = '90'; + +// --------------------------------------------------------------------------- +// 8. Brute-force: exact threshold, throttle boundary, concurrency +// --------------------------------------------------------------------------- + +audit_test_reset_state(); + +// Below threshold: no emit. +$audit_auth_failed_count = 9; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Below threshold must not emit.'); + +// Exactly at threshold: emit. +$audit_auth_failed_count = 10; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Exactly at threshold must emit even when the throttle setting row did not previously exist.'); +audit_test_assert_true(read_config_option('audit_brute_force_last_alert') !== '', 'Brute-force detection must initialize its throttle setting row.'); +audit_test_assert_same('cacti.auth.brute_force_suspected', $audit_auth_recorded_events[0]['params'][12], 'Brute-force event type must be correct.'); +audit_test_assert_same('critical', $audit_auth_recorded_events[0]['params'][14], 'Brute-force must be critical.'); + +// Within window: throttled (atomic UPDATE claims nothing). +$audit_auth_failed_count = 12; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Within the window, the atomic claim must throttle the second alert.'); + +// Concurrent check: second poller's UPDATE affects 0 rows. +$audit_auth_failed_count = 12; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A concurrent poller must not emit a duplicate alert.'); + +// Failed audit insert releases the slot. +audit_test_reset_state(); +$audit_auth_settings_rows['audit_brute_force_last_alert'] = ''; +$audit_auth_failed_count = 10; +$audit_auth_insert_fails = true; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same('', $audit_auth_settings_rows['audit_brute_force_last_alert'] ?? '', 'A failed audit insert must release the alert slot for retry.'); +$audit_auth_insert_fails = false; + +// Disabled must not emit. +$audit_auth_config['audit_brute_force_enabled'] = 'off'; +$audit_auth_failed_count = 50; +$audit_auth_settings_rows['audit_brute_force_last_alert'] = ''; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Disabled brute-force detection must not emit.'); +$audit_auth_config['audit_brute_force_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 9. custom_denied: returns mode, records event, redacts referer +// --------------------------------------------------------------------------- + +$_SESSION['sess_user_id'] = 5; +$_SERVER['SCRIPT_NAME'] = '/cacti/host.php'; +$_SERVER['HTTP_REFERER'] = 'https://cacti.example.com/index.php?token=secret&reset_hash=abc123'; +audit_test_reset_state(); + +$returned = audit_custom_denied('OPER_MODE_NATIVE'); +audit_test_assert_same('OPER_MODE_NATIVE', $returned, 'audit_custom_denied() must return the input mode unchanged.'); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'audit_custom_denied() must record one event.'); +audit_test_assert_same('cacti.auth.authorization.denied', $audit_auth_recorded_events[0]['params'][12], 'Denied event type must be correct.'); +$details_json = $audit_auth_recorded_events[0]['params'][24]; +$details = json_decode($details_json, true); +audit_test_assert_same('https://cacti.example.com/index.php', $details['referer_origin'], 'Referer query string must be stripped.'); +audit_test_assert_true(strpos($details_json, 'secret') === false, 'The referer token must not appear in details.'); +audit_test_assert_true(strpos($details_json, 'abc123') === false, 'The reset hash must not appear in details.'); + +// Disabled must not record but still return the mode. +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +$audit_auth_recorded_events = []; +audit_test_assert_same('OPER_MODE_NATIVE', audit_custom_denied('OPER_MODE_NATIVE'), 'audit_custom_denied() must always return the input mode.'); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'audit_custom_denied() must skip recording when disabled.'); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 10. Logout: master switch gates pre-destroy; post-destroy uses stash +// --------------------------------------------------------------------------- + +$_SESSION['sess_user_id'] = 5; +$_REQUEST['action'] = 'user'; +audit_test_reset_state(); + +audit_logout_pre_session_destroy(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Pre-destroy must record the logout event.'); +audit_test_assert_same('authentication.logout', $audit_auth_recorded_events[0]['params'][12], 'Pre-destroy event type must be correct.'); + +$audit_auth_recorded_events = []; +audit_logout_post_session_destroy(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Post-destroy must record one completed event.'); +audit_test_assert_same('authentication.logout.completed', $audit_auth_recorded_events[0]['params'][12], 'Post-destroy event type must be correct.'); +audit_test_assert_same(5, $audit_auth_recorded_events[0]['params'][1], 'Post-destroy must carry the stashed user_id.'); + +// Empty stash: no record. +$audit_auth_recorded_events = []; +audit_logout_post_session_destroy(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Post-destroy must not record when the stash is empty.'); + +// Master switch off: pre-destroy must not record. +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +$audit_auth_recorded_events = []; +audit_logout_pre_session_destroy(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Pre-destroy must not record when auth auditing is disabled.'); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 11. Unauthorized auth-settings save uses the generic event type +// --------------------------------------------------------------------------- + +// audit_enforce_syslog_settings_request() reads POST via filter_input_array +// (a PHP built-in reading the real SAPI POST body) and calls exit on 403, +// so it cannot be exercised behaviorally in a CLI test. Verify via a static +// source assertion that the auth-only unauthorized path emits the generic +// audit.configuration.denied event, distinct from the syslog-specific one. +$functions_source = file_get_contents(dirname(__DIR__) . '/audit_functions.php'); +audit_test_assert_true( + strpos($functions_source, "'audit.configuration.denied'") !== false, + 'The generic audit.configuration.denied event must be present for unauthorized auth-settings saves.' +); +audit_test_assert_true( + strpos($functions_source, "'audit.syslog.configuration.denied'") !== false, + 'The syslog-specific denied event must remain for unauthorized syslog-settings saves.' +); +// Confirm the auth-only branch exists (else branch after the syslog check). +audit_test_assert_true( + strpos($functions_source, 'audit.configuration.denied') !== false + && strpos($functions_source, 'audit_admin_required') !== false, + 'The unauthorized auth-settings save must record an audit_admin_required denied event.' +); + +print "Auth audit tests passed.\n"; diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 80ba0c0..40b348b 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -41,6 +41,16 @@ 'audit_retry_external_logs()', 'audit_process_syslog_queue()', 'logout_pre_session_destroy', + 'logout_post_session_destroy', + 'custom_denied', + 'audit_poll_user_log()', + 'audit_detect_brute_force()', + 'audit_auth_log_enabled', + 'audit_brute_force_enabled', + 'audit_user_log_batch_size', + 'audit_persist_auth_defaults', + 'CREATE TABLE IF NOT EXISTS `audit_user_log_state`', + 'DROP TABLE IF EXISTS audit_user_log_state', 'event_uuid char(36)', 'operation_outcome', 'external_attempts', @@ -63,6 +73,37 @@ "register_shutdown_function('audit_finalize_request', \$audit_id, \$started_at, \$verifier)" ]; +$required_auth_fragments = [ + 'function audit_poll_user_log', + 'function audit_detect_brute_force', + 'function audit_custom_denied', + 'function audit_logout_post_session_destroy', + 'function audit_user_log_event_descriptor', + "'cacti.auth.login.failed'", + "'cacti.auth.login.credentials_accepted'", + "'cacti.auth.login.token'", + "'cacti.auth.password.changed'", + "'cacti.auth.password_change_or_2fa_failed'", + "'cacti.auth.login.unknown'", + "'cacti.auth.brute_force_suspected'", + "'cacti.auth.authorization.denied'", + "'authentication.logout.completed'", + "'audit.configuration.denied'", + "'START TRANSACTION'", + "'ROLLBACK'", + "'COMMIT'", + "'defer_delivery'", + 'FROM audit_user_log_state AS auls', + 'INSERT IGNORE INTO settings (name, value)' +]; + +foreach ($required_auth_fragments as $fragment) { + if (strpos($functions, $fragment) === false) { + fwrite(STDERR, 'Missing authentication audit requirement: ' . $fragment . PHP_EOL); + exit(1); + } +} + foreach ($required_verifier_fragments as $fragment) { if (strpos($functions, $fragment) === false) { fwrite(STDERR, 'Missing operation verification requirement: ' . $fragment . PHP_EOL); From 9b12b97a50de8713bde73178e54780ec0f493d13 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:13:21 -0400 Subject: [PATCH 32/36] fix syntax --- audit_functions.php | 8 ++++---- tests/auth_audit_test.php | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/audit_functions.php b/audit_functions.php index 90268e1..3ae3cf4 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -962,10 +962,10 @@ function audit_poll_user_log(): void { CONCAT(ul.username, "|", ul.user_id, "|", ul.time), 256 ) - ) - ORDER BY ul.time ASC, ul.username ASC, ul.user_id ASC - LIMIT ?', - [$cutoff, $batch_size] + ) + ORDER BY ul.time ASC, ul.username ASC, ul.user_id ASC + LIMIT ' . $batch_size, + [$cutoff] ); if (!is_array($rows) || cacti_sizeof($rows) === 0) { diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php index f043aa5..3fb34d6 100644 --- a/tests/auth_audit_test.php +++ b/tests/auth_audit_test.php @@ -233,8 +233,9 @@ function db_fetch_assoc_prepared(string $sql, array $params = []): array { return $a['user_id'] <=> $b['user_id']; }); - $limit_idx = count($params) - 1; - $limit = (int) ($params[$limit_idx] ?? 1000); + $limit = preg_match('/LIMIT\\s+(\\d+)/i', $sql, $matches) + ? (int) $matches[1] + : 1000; return array_slice($filtered, 0, $limit); } From ddba16cb0f1b54ea6555e82077e6def78894919e Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:22:08 -0400 Subject: [PATCH 33/36] Fix cleanup during uninstall --- .github/workflows/plugin-ci-workflow.yml | 20 +++++++++++++++ CHANGELOG.md | 2 ++ audit_functions.php | 18 +++++++++++--- audit_syslog.php | 4 ++- setup.php | 4 +++ tests/auth_audit_test.php | 31 +++++++++++++++++++++--- tests/controller_security_test.php | 2 ++ tests/syslog_queue_test.php | 2 +- 8 files changed, 75 insertions(+), 8 deletions(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 451d674..b615982 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -343,3 +343,23 @@ jobs: echo "Unexpected CLI request status: $CLI_STATUS" exit 1 fi + + - name: Verify plugin uninstall cleanup + run: | + cd ${{ github.workspace }}/cacti + sudo php cli/plugin_manage.php --plugin=audit --disable --uninstall + + AUDIT_SETTING_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) FROM settings WHERE LEFT(name, 6) = 'audit_'; + ") + AUDIT_TABLE_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'cacti' + AND table_name IN ('audit_log', 'audit_syslog_delivery', 'audit_user_log_state'); + ") + + if [ "$AUDIT_SETTING_COUNT" -ne 0 ] || [ "$AUDIT_TABLE_COUNT" -ne 0 ]; then + echo "Audit plugin uninstall left settings or tables behind" + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d7823..4f43e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ * security: Restrict authentication auditing and brute-force detection settings to Audit Log Admin users and enforce authorization on save * security: Gate the original logout event behind the authentication auditing master switch * security: Persist authentication defaults on install and upgrade without overwriting existing administrator choices +* issue: Remove all plugin-owned audit settings during uninstall +* issue: Make late request-finalization callbacks safe after plugin tables are removed * issue: Test integration CI against both Cacti 1.2.x and develop branches * feature: Add standards-based remote Syslog delivery over UDP, TCP, and verified TLS diff --git a/audit_functions.php b/audit_functions.php index 3ae3cf4..95ba38e 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -6,6 +6,10 @@ function audit_user_is_admin(): bool { return api_plugin_user_realm_auth('audit_manage.php'); } +function audit_log_table_available(): bool { + return function_exists('db_table_exists') && db_table_exists('audit_log'); +} + /** * @param array $selected_items */ @@ -428,6 +432,10 @@ function audit_append_external_log(string $path, string $message): array { } function audit_set_external_status(int $id, string $status, string $error = ''): void { + if (!audit_log_table_available()) { + return; + } + db_execute_prepared('UPDATE audit_log SET external_status = ?, external_error = ?, @@ -439,7 +447,7 @@ function audit_set_external_status(int $id, string $status, string $error = ''): } function audit_deliver_external_event(int $id): void { - if (read_config_option('audit_log_external') != 'on') { + if (!audit_log_table_available() || read_config_option('audit_log_external') != 'on') { return; } @@ -464,7 +472,7 @@ function audit_deliver_external_event(int $id): void { } function audit_retry_external_logs(): void { - if (read_config_option('audit_log_external') != 'on') { + if (!audit_log_table_available() || read_config_option('audit_log_external') != 'on') { return; } @@ -624,6 +632,10 @@ function audit_verify_operation(mixed $verifier): array { * @param array|null $verifier */ function audit_finalize_request(int $id, ?float $started_at = null, ?array $verifier = null): void { + if (!audit_log_table_available()) { + return; + } + $status_code = http_response_code(); $status_code = is_int($status_code) ? $status_code : 200; $request_status = audit_request_status(error_get_last(), $status_code); @@ -665,7 +677,7 @@ function audit_finalize_request(int $id, ?float $started_at = null, ?array $veri * @param array $options */ function audit_record_event(string $event_type, array $options = []): int { - if (read_config_option('audit_enabled') != 'on') { + if (!audit_log_table_available() || read_config_option('audit_enabled') != 'on') { return 0; } diff --git a/audit_syslog.php b/audit_syslog.php index 10ff4f0..46245bf 100644 --- a/audit_syslog.php +++ b/audit_syslog.php @@ -709,7 +709,9 @@ function audit_syslog_send_event(array $event, array $config, mixed &$socket = n } function audit_enqueue_syslog_event(int $audit_id): void { - if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) { + if (!audit_syslog_enabled() || + !db_table_exists('audit_log') || + !db_table_exists('audit_syslog_delivery')) { return; } diff --git a/setup.php b/setup.php index 9d2eaae..8c7761b 100644 --- a/setup.php +++ b/setup.php @@ -139,6 +139,10 @@ function plugin_audit_uninstall(): bool { db_execute('DROP TABLE IF EXISTS audit_user_log_state'); db_execute('DROP TABLE IF EXISTS audit_syslog_delivery'); db_execute('DROP TABLE IF EXISTS audit_log'); + db_execute_prepared( + 'DELETE FROM settings WHERE LEFT(name, 6) = ?', + ['audit_'] + ); return true; } diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php index 3fb34d6..fecf15b 100644 --- a/tests/auth_audit_test.php +++ b/tests/auth_audit_test.php @@ -32,6 +32,8 @@ $audit_auth_state_conflict = false; $audit_auth_affected_rows = 0; $audit_auth_transaction = null; +$audit_auth_log_exists = true; +$audit_auth_executed_sql = []; function read_config_option(string $name, bool $force = false): string { global $audit_auth_config, $audit_auth_settings_rows; @@ -51,7 +53,9 @@ function set_config_option(string $name, string $value): void { } function db_execute_prepared(string $sql, array $params = []): bool { - global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction; + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction, $audit_auth_executed_sql; + + $audit_auth_executed_sql[] = $sql; if (strpos($sql, 'START TRANSACTION') !== false) { $audit_auth_transaction = [ @@ -263,6 +267,12 @@ function db_affected_rows(): int { } function db_table_exists(string $table): bool { + global $audit_auth_log_exists; + + if ($table === 'audit_log') { + return $audit_auth_log_exists; + } + return in_array($table, ['user_log', 'audit_user_log_state'], true); } @@ -310,7 +320,7 @@ function audit_test_assert_true(bool $condition, string $message): void { } function audit_test_reset_state(): void { - global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_set_options, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction; + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_set_options, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction, $audit_auth_log_exists, $audit_auth_executed_sql; $audit_auth_recorded_events = []; $audit_auth_state_rows = []; $audit_auth_set_options = []; @@ -320,6 +330,8 @@ function audit_test_reset_state(): void { $audit_auth_state_conflict = false; $audit_auth_affected_rows = 0; $audit_auth_transaction = null; + $audit_auth_log_exists = true; + $audit_auth_executed_sql = []; } // --------------------------------------------------------------------------- @@ -631,7 +643,20 @@ function audit_test_reset_state(): void { $audit_auth_config['audit_auth_log_enabled'] = 'on'; // --------------------------------------------------------------------------- -// 11. Unauthorized auth-settings save uses the generic event type +// 11. Uninstall lifecycle: shutdown callbacks tolerate removed tables +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_log_exists = false; + +audit_test_assert_same(0, audit_record_event('audit.test.after_uninstall'), 'Recording must no-op after audit_log is removed.'); +audit_finalize_request(123); +audit_deliver_external_event(123); +audit_retry_external_logs(); +audit_test_assert_same([], $audit_auth_executed_sql, 'Late callbacks must not query audit_log after plugin uninstall.'); + +// --------------------------------------------------------------------------- +// 12. Unauthorized auth-settings save uses the generic event type // --------------------------------------------------------------------------- // audit_enforce_syslog_settings_request() reads POST via filter_input_array diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 40b348b..b9f19c7 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -51,6 +51,8 @@ 'audit_persist_auth_defaults', 'CREATE TABLE IF NOT EXISTS `audit_user_log_state`', 'DROP TABLE IF EXISTS audit_user_log_state', + "'DELETE FROM settings WHERE LEFT(name, 6) = ?'", + "['audit_']", 'event_uuid char(36)', 'operation_outcome', 'external_attempts', diff --git a/tests/syslog_queue_test.php b/tests/syslog_queue_test.php index b88913c..eb3b4a9 100644 --- a/tests/syslog_queue_test.php +++ b/tests/syslog_queue_test.php @@ -31,7 +31,7 @@ function read_config_option($name) { } function db_table_exists($table) { - return $table === 'audit_syslog_delivery'; + return in_array($table, ['audit_log', 'audit_syslog_delivery'], true); } function db_fetch_row_prepared($sql, $params = []) { From 7b4b954dcc53e6f65029ea032a6d891c7801e676 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:26:32 -0400 Subject: [PATCH 34/36] . --- CHANGELOG.md | 3 --- audit.php | 32 +++++++++++++++++++----------- tests/controller_security_test.php | 9 ++++++++- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f43e4f..366a7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,6 @@ * security: Restrict authentication auditing and brute-force detection settings to Audit Log Admin users and enforce authorization on save * security: Gate the original logout event behind the authentication auditing master switch * security: Persist authentication defaults on install and upgrade without overwriting existing administrator choices -* issue: Remove all plugin-owned audit settings during uninstall -* issue: Make late request-finalization callbacks safe after plugin tables are removed -* issue: Test integration CI against both Cacti 1.2.x and develop branches * feature: Add standards-based remote Syslog delivery over UDP, TCP, and verified TLS * feature: Add RFC 5424 headers with RFC 5424, CEF, or compact JSON message formats diff --git a/audit.php b/audit.php index dcbf213..5347f98 100644 --- a/audit.php +++ b/audit.php @@ -177,7 +177,7 @@ function audit_render_event_details(array $data): string { $output .= '
' . __('External File Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; } - if (db_table_exists('audit_syslog_delivery')) { + if (audit_syslog_enabled() && db_table_exists('audit_syslog_delivery')) { $syslog = db_fetch_row_prepared('SELECT state, attempts, last_attempt, sent_time, last_error FROM audit_syslog_delivery WHERE audit_id = ? @@ -185,16 +185,21 @@ function audit_render_event_details(array $data): string { LIMIT 1', [$data['id']]); - if (is_array($syslog)) { - $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($syslog['state']) . ''; - $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . (int) $syslog['attempts'] . ''; + if (is_array($syslog) && cacti_sizeof($syslog) > 0) { + $state = (string) ($syslog['state'] ?? 'unknown'); + $attempts = (int) ($syslog['attempts'] ?? 0); + $sent_time = (string) ($syslog['sent_time'] ?? ''); + $last_error = (string) ($syslog['last_error'] ?? ''); - if ($syslog['sent_time'] != '') { - $output .= '
' . __('Syslog Socket Write:', 'audit') . ' ' . html_escape($syslog['sent_time']) . ''; + $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($state) . ''; + $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . $attempts . ''; + + if ($sent_time != '') { + $output .= '
' . __('Syslog Socket Write:', 'audit') . ' ' . html_escape($sent_time) . ''; } - if ($syslog['last_error'] != '') { - $output .= '
' . __('Syslog Error:', 'audit') . ' ' . html_escape($syslog['last_error']) . ''; + if ($last_error != '') { + $output .= '
' . __('Syslog Error:', 'audit') . ' ' . html_escape($last_error) . ''; } } } @@ -720,10 +725,13 @@ function audit_log(): void { } function audit_render_syslog_health(): void { + if (!audit_syslog_enabled()) { + return; + } + $config = audit_syslog_config(); $health = audit_syslog_health(); - $enabled = audit_syslog_enabled(); - $unhealthy = $enabled && ( + $unhealthy = ( !$config['valid'] || $health['dead_letter'] >= $config['dead_letter_warning'] || $health['oldest_pending_seconds'] >= $config['pending_age_warning'] @@ -733,7 +741,7 @@ function audit_render_syslog_health(): void { print "
' . __('Status', 'audit') . '' . html_escape(!$enabled ? __('Disabled', 'audit') : ($unhealthy ? __('Unhealthy', 'audit') : __('Healthy', 'audit'))) . '' . html_escape($unhealthy ? __('Unhealthy', 'audit') : __('Healthy', 'audit')) . '' . __('Pending', 'audit') . '' . (int) $health['pending'] . '' . __('Retry', 'audit') . '' . (int) $health['retry'] . '' . __('Dead-letter', 'audit') . '' . (int) $health['dead_letter'] . '
" . __esc('Configuration Error:', 'audit') . ' ' . html_escape(implode(', ', $config['errors'])) . '