diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index a5b0a9d..672dbaf 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3'] + php: ['8.4'] os: [ubuntu-latest] services: @@ -64,6 +64,7 @@ jobs: uses: actions/checkout@v4 with: repository: Cacti/cacti + ref: 1.2.x path: cacti - name: Checkout audit Plugin @@ -86,7 +87,7 @@ jobs: run: sudo apt-get update - name: Install System Dependencies - run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping libapache2-mod-php${{ matrix.php }} + run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping - name: Start SNMPD Agent and Test run: | @@ -243,7 +244,7 @@ jobs: fi 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 + if [ "$CLI_STATUS" != "completed" ]; then echo "Unexpected CLI request status: $CLI_STATUS" exit 1 fi 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",