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",