diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 4a634d0..befe032 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -184,6 +184,17 @@ jobs: echo "Audit schema security columns are missing" exit 1 fi + + SYSLOG_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_syslog_delivery'; + ") + if [ "$SYSLOG_TABLE_COUNT" -ne 1 ]; then + echo "Audit Syslog delivery queue table is missing" + exit 1 + fi - name: Check PHP Syntax for Plugin run: | @@ -198,6 +209,8 @@ jobs: cd ${{ github.workspace }}/cacti/plugins/audit php tests/security_functions_test.php php tests/controller_security_test.php + php tests/syslog_functions_test.php + php tests/syslog_queue_test.php - name: Run Cacti Poller run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb9096..d56164f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ --- develop --- +* 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 +* 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/INFO b/INFO index 6510278..fe5f4b5 100644 --- a/INFO +++ b/INFO @@ -21,7 +21,7 @@ [info] name = audit -version = 1.4 +version = 1.5 longname = Audit Plugin for Cacti author = The Cacti Group email = diff --git a/README.md b/README.md index fd51efc..cbffe64 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,63 @@ 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`. +## Remote Syslog + +Version 1.5 adds standards-based Remote Syslog as the plugin's only remote +network transport. The plugin does not implement product-specific webhooks, +Splunk HEC, or Microsoft Sentinel APIs. Splunk, Sentinel, and other SIEM +products can ingest the existing audit file or receive Syslog through their +normal collector or forwarder. + +Remote Syslog is configured under Configuration -> Settings -> Audit by a user +with the Audit Log Admin permission. It is independent of the existing +external-file option, so either destination or both destinations may be +enabled. + +The available settings include: + +* Receiver hostname or IP address and an optional explicit port. +* UDP, TCP, or TLS transport. A blank port uses 514 for UDP/TCP or 6514 for TLS. +* RFC 5424 structured data, CEF, or compact JSON message payload. +* Syslog facility, application name, stable Cacti node identity, and poller + identity. +* Bounded connection/write timeout, poller batch size, retry attempts, and + exponential-backoff delays. +* Maximum UDP record size and health-warning thresholds. +* Optional TLS CA bundle and mutual-TLS client certificate/key paths. + +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 +transport reliability matters. + +Remote network I/O does not run in the originating web request. Finalized events +are added to `audit_syslog_delivery` and sent in bounded batches by the Cacti +poller. Transient connection and write errors use bounded exponential backoff. +Permanent formatting/configuration errors and exhausted retries enter +dead-letter state. Audit Log Admin can test the stored configuration and reset +dead-letter events from the Audit Log page; both actions require CSRF-protected +POST requests and are audited. + +A successful UDP, TCP, or TLS socket write is shown as `sent_unconfirmed`. This +means the local operating system accepted the complete record; standard Syslog +does not confirm that a receiver durably stored it. Every transmission retains +the same `event_uuid`, which receivers should use as their deduplication key. + +The Audit Log page reports pending, retry, sent-unconfirmed, and dead-letter +counts, oldest pending age, last attempt, last socket write, and the latest +bounded error. Scheduled retention and manual purge preserve events with +pending, retry, or dead-letter Syslog delivery. + 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 diff --git a/audit.php b/audit.php index 19c26e8..8703ec0 100644 --- a/audit.php +++ b/audit.php @@ -31,6 +31,77 @@ 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; + } + + if (!audit_user_is_admin() || !csrf_check(false)) { + http_response_code(403); + 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'); + + 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; + } + + $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'); + + top_header(); + audit_log(); + bottom_footer(); + break; case 'purge': if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') { @@ -95,9 +166,29 @@ function audit_render_event_details($data) { if ($data['outcome_reason'] != '') { $output .= '
' . __('Outcome Reason:', 'audit') . ' ' . html_escape($data['outcome_reason']) . ''; } - $output .= '
' . __('External Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . ''; + $output .= '
' . __('External File Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . ''; if ($data['external_error'] != '') { - $output .= '
' . __('External Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; + $output .= '
' . __('External File Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; + } + + if (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 = ? + ORDER BY id DESC + LIMIT 1', + array($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']) . ''; + } + } } $output .= '
'; @@ -164,16 +255,37 @@ function audit_render_value($value) { } function audit_purge() { - db_execute('TRUNCATE TABLE audit_log'); + $protected = db_fetch_cell("SELECT COUNT(*) + FROM audit_log + WHERE 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') + )"); + + db_execute("DELETE FROM audit_log + WHERE 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') + )"); + $purged = db_affected_rows(); audit_record_event('audit.log.purged', array( 'event_category' => 'audit', 'severity' => 'warning', 'action' => 'purge', - 'target_type' => 'audit_log' + 'target_type' => 'audit_log', + 'details' => array( + 'purged_count' => $purged, + 'protected_count' => (int) $protected + ) )); - $_SESSION['audit_message'] = __('Audit Log Purged by %s', get_username($_SESSION['sess_user_id']), 'audit'); + $_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'); cacti_log('NOTE: Audit Log Purged by ' . get_username($_SESSION['sess_user_id']), false, 'WEBUI'); @@ -342,6 +454,10 @@ function audit_log() { $rows = get_request_var('rows'); } + if (audit_user_is_admin()) { + audit_render_syslog_health(); + } + html_start_box(__('Audit Log', 'audit'), '100%', '', '3', 'center', ''); ?> @@ -499,10 +615,10 @@ function audit_log() { 'tip' => __('Request processing state; completion does not guarantee that every operation succeeded.', 'audit') ), 'external_status' => array( - 'display' => __('External Delivery', 'audit'), + 'display' => __('External File Delivery', 'audit'), 'align' => 'left', 'sort' => 'ASC', - 'tip' => __('Delivery state for the optional external audit log.', 'audit') + 'tip' => __('Delivery state for the optional external audit log file. Remote Syslog health is shown separately.', 'audit') ), 'user_agent' => array( 'display' => __('User Agent', 'audit'), @@ -567,3 +683,47 @@ function audit_log() { = $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..f73f406 --- /dev/null +++ b/audit_syslog.php @@ -0,0 +1,899 @@ + $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_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( + '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'], + '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(); + + 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_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(); + + 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; + $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) + ); + } + + 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_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') { + $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); + } + } else { + $length = strlen($message); + $offset = 0; + + while ($offset < $length) { + $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.' + : ($stream_warning !== '' ? $stream_warning : '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/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..0adf595 --- /dev/null +++ b/tests/syslog_functions_test.php @@ -0,0 +1,446 @@ + '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', + '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); + $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.' +); + +$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.'); + +$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.'); +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); +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";