diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 51972b9..4a634d0 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -238,3 +238,8 @@ jobs: exit 1 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" != "completed" ]; then + echo "Unexpected CLI request status: $CLI_STATUS" + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 8954bc4..aeb9096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ --- develop --- +* 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 * feature: Deliver finalized request outcomes to external log consumers * feature: Audit audit-log views, searches, event detail access, exports, and purges diff --git a/README.md b/README.md index b2bd0d8..fd51efc 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,22 @@ 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. +For direct user realm permission saves, the plugin verifies the resulting +`user_auth_realm` rows against the submitted realm set after Cacti processes the +request. Matching state is recorded as `success` with outcome reason +`realm_permissions_verified`; a mismatch is recorded as `failure`. + 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. +## Permissions + +The plugin groups its Cacti realms under the Audit Plugin permissions section. +Audit Log User permits access to the audit log. Audit Log Admin permits plugin +administration and purging audit events. + ## Possible Bugs If you figure out this problem, see the Cacti forums! diff --git a/audit.php b/audit.php index 67e7efd..19c26e8 100644 --- a/audit.php +++ b/audit.php @@ -39,7 +39,7 @@ exit; } - if (!api_plugin_user_realm_auth('audit_manage.php') || !csrf_check(false)) { + if (!audit_user_is_admin() || !csrf_check(false)) { http_response_code(403); exit; } @@ -92,6 +92,9 @@ function audit_render_event_details($data) { $output .= '
' . __('Event ID:', 'audit') . ' ' . html_escape($data['event_uuid']) . ''; $output .= '
' . __('Request Status:', 'audit') . ' ' . html_escape($data['request_status']) . ''; $output .= '
' . __('Operation Outcome:', 'audit') . ' ' . html_escape($data['operation_outcome']) . ''; + if ($data['outcome_reason'] != '') { + $output .= '
' . __('Outcome Reason:', 'audit') . ' ' . html_escape($data['outcome_reason']) . ''; + } $output .= '
' . __('External Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . ''; if ($data['external_error'] != '') { $output .= '
' . __('External Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; @@ -407,7 +410,7 @@ function audit_log() { - + diff --git a/audit_functions.php b/audit_functions.php index 882f50c..0d56410 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -1,5 +1,9 @@ 'invalid', + 'outcome_reason' => 'realm_permissions_request_invalid' + ); + } + + $expected_realm_ids = array(); + foreach ($post as $field => $value) { + $field = (string) $field; + + if (strpos($field, 'section') !== 0) { + continue; + } + + if (!preg_match('/^section([1-9][0-9]*)$/', $field, $matches)) { + return array( + 'type' => 'invalid', + 'outcome_reason' => 'realm_permissions_request_invalid' + ); + } + + $expected_realm_ids[] = (int) $matches[1]; + } + + $expected_realm_ids = array_values(array_unique($expected_realm_ids)); + sort($expected_realm_ids, SORT_NUMERIC); + + return array( + 'type' => 'user_realm_permissions', + 'target_user_id' => (int) $target_user_id, + 'expected_realm_ids' => $expected_realm_ids + ); +} + +function audit_verify_operation($verifier) { + if (!is_array($verifier) || empty($verifier['type'])) { + return array('outcome' => 'unknown', 'reason' => null); + } + + if ($verifier['type'] == 'invalid') { + return array( + 'outcome' => 'unknown', + 'reason' => $verifier['outcome_reason'] ?? 'verification_request_invalid' + ); + } + + if ($verifier['type'] != 'user_realm_permissions') { + return array('outcome' => 'unknown', 'reason' => 'verification_type_unsupported'); + } + + $target_user_id = (int) ($verifier['target_user_id'] ?? 0); + $expected_realm_ids = $verifier['expected_realm_ids'] ?? array(); + $user_count = db_fetch_cell_prepared('SELECT COUNT(*) FROM user_auth WHERE id = ?', array($target_user_id)); + + if ($user_count === false) { + return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'); + } + + if ((int) $user_count !== 1) { + return array('outcome' => 'failure', 'reason' => 'target_user_not_found'); + } + + $rows = db_fetch_assoc_prepared('SELECT realm_id + FROM user_auth_realm + WHERE user_id = ? + ORDER BY realm_id', + array($target_user_id)); + + if (!is_array($rows)) { + return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'); + } + + $actual_realm_ids = array(); + foreach ($rows as $row) { + if (!isset($row['realm_id']) || !is_numeric($row['realm_id'])) { + return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'); + } + + $actual_realm_ids[] = (int) $row['realm_id']; + } + + $actual_realm_ids = array_values(array_unique($actual_realm_ids)); + sort($actual_realm_ids, SORT_NUMERIC); + + if ($actual_realm_ids === $expected_realm_ids) { + return array('outcome' => 'success', 'reason' => 'realm_permissions_verified'); + } + + return array('outcome' => 'failure', 'reason' => 'realm_permissions_mismatch'); +} + +function audit_finalize_request($id, $started_at = null, $verifier = null) { $status_code = http_response_code(); $status_code = is_int($status_code) ? $status_code : 200; $request_status = audit_request_status(error_get_last(), $status_code); $outcome = $request_status == 'failed' ? 'failure' : 'unknown'; + $outcome_reason = $request_status == 'failed' ? 'request_failed' : null; + + if ($request_status == 'completed' && $verifier !== null) { + $verification = audit_verify_operation($verifier); + $outcome = $verification['outcome']; + $outcome_reason = $verification['reason']; + } + $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 = ?, + outcome_reason = CASE WHEN operation_outcome = 'unknown' THEN ? ELSE outcome_reason END, 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, $outcome, $status_code, $completed_time, $duration_ms, $id)); + array($request_status, $outcome_reason, $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)) { @@ -580,8 +692,9 @@ function audit_config_insert() { } $target_id = $post['id'] ?? null; - $post = audit_json_encode($post); $page = basename($_SERVER['SCRIPT_NAME']); + $verifier = audit_operation_verifier_for_request($page, $post); + $post = audit_json_encode($post); $user_id = (isset($_SESSION['sess_user_id']) ? $_SESSION['sess_user_id'] : 0); $event_time = audit_utc_time($started_at); @@ -652,7 +765,7 @@ function audit_config_insert() { $_SERVER['REQUEST_METHOD'] ?? null )); $audit_id = db_fetch_insert_id(); - register_shutdown_function('audit_finalize_request', $audit_id, $started_at); + register_shutdown_function('audit_finalize_request', $audit_id, $started_at, $verifier); if ($external_logging && $audit_log == '') { set_config_option('audit_log_external_path', $base . '/log/audit.log'); diff --git a/setup.php b/setup.php index 5ccb4e3..4c1be32 100644 --- a/setup.php +++ b/setup.php @@ -37,12 +37,69 @@ function plugin_audit_install() { /* hook for table replication */ api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php'); - api_plugin_register_realm('audit', 'audit.php', __('View Cacti Audit Log', 'audit'), 1); - api_plugin_register_realm('audit', 'audit_manage.php', __('Manage Cacti Audit Log', 'audit'), 1); + audit_setup_realms(true); audit_setup_table(); } +function audit_setup_realms($grant_installing_user = false) { + $realms = array( + 'audit.php' => __('Audit Log User', 'audit'), + 'audit_manage.php' => __('Audit Log Admin', 'audit') + ); + + foreach ($realms as $file => $display) { + api_plugin_register_realm('audit', $file, $display, $grant_installing_user ? 1 : 0); + } + + if (!$grant_installing_user) { + $admin_user = (int) read_config_option('admin_user'); + + if ($admin_user > 0) { + $realm_ids = db_fetch_assoc_prepared('SELECT id + 100 AS realm_id + FROM plugin_realms + WHERE plugin = ? + AND file IN (?, ?)', + array('audit', 'audit.php', 'audit_manage.php')); + + foreach ($realm_ids as $realm) { + db_execute_prepared('REPLACE INTO user_auth_realm + (user_id, realm_id) + VALUES (?, ?)', + array($admin_user, $realm['realm_id'])); + } + } + } +} + +function audit_remove_deprecated_realms() { + $realms = db_fetch_assoc_prepared('SELECT id + FROM plugin_realms + WHERE plugin = ? + AND file = ?', + array('audit', 'audit_purge.php')); + + foreach ($realms as $realm) { + $realm_id = $realm['id'] + 100; + + db_execute_prepared('DELETE FROM user_auth_realm + WHERE realm_id = ?', + array($realm_id)); + + db_execute_prepared('DELETE FROM user_auth_group_realm + WHERE realm_id = ?', + array($realm_id)); + + db_execute_prepared('DELETE FROM plugin_realms + WHERE id = ?', + array($realm['id'])); + } + + if (cacti_sizeof($realms)) { + api_plugin_replicate_config(); + } +} + function plugin_audit_uninstall() { db_execute('DROP TABLE IF EXISTS audit_log'); return true; @@ -103,6 +160,8 @@ 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_realms(); + audit_remove_deprecated_realms(); db_execute_prepared('UPDATE plugin_config SET version = ? @@ -121,7 +180,6 @@ function audit_check_upgrade() { 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); } } @@ -378,7 +436,7 @@ function audit_config_arrays() { $menu[__('Utilities')]['plugins/audit/audit.php'] = __('Audit Log', 'audit'); if (function_exists('auth_augment_roles')) { - auth_augment_roles(__('System Administration'), array('audit.php')); + auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php')); } audit_check_upgrade(); diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 0f38d6d..890b682 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -1,14 +1,16 @@ __('Audit Log User'", + "'audit_manage.php' => __('Audit Log Admin'", + 'audit_setup_realms(true)', + 'audit_setup_realms()', + 'audit_remove_deprecated_realms()', + "auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'))", 'api_plugin_register_hook(\'audit\', \'replicate_out\'', 'request_status', 'ADD COLUMN IF NOT EXISTS external_status', @@ -41,6 +48,35 @@ } } +$required_verifier_fragments = array( + 'audit_operation_verifier_for_request', + "'user_realm_permissions'", + "'realm_permissions_verified'", + "register_shutdown_function('audit_finalize_request', \$audit_id, \$started_at, \$verifier)" +); + +foreach ($required_verifier_fragments as $fragment) { + if (strpos($functions, $fragment) === false) { + fwrite(STDERR, 'Missing operation verification requirement: ' . $fragment . PHP_EOL); + exit(1); + } +} + +if (strpos($functions, "api_plugin_user_realm_auth('audit_manage.php')") === false) { + fwrite(STDERR, 'Audit administrators must be authorized to purge.' . PHP_EOL); + exit(1); +} + +if (strpos($functions, "api_plugin_user_realm_auth('audit_purge.php')") !== false) { + fwrite(STDERR, 'The deprecated delegated purge permission must not authorize purge.' . PHP_EOL); + exit(1); +} + +if (substr_count($controller, 'audit_user_is_admin()') < 2) { + fwrite(STDERR, 'Purge authorization must protect both the action and its UI control.' . PHP_EOL); + exit(1); +} + if (strpos($javascript, "loadPageNoHeader('audit.php?action=purge") !== false) { fwrite(STDERR, 'Purge must not use the legacy GET request path.' . PHP_EOL); exit(1); diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php index a87b1b3..4a736a5 100644 --- a/tests/security_functions_test.php +++ b/tests/security_functions_test.php @@ -2,6 +2,45 @@ require_once dirname(__DIR__) . '/audit_functions.php'; +$audit_test_realms = array(); +$audit_test_existing_users = array(); +$audit_test_user_realms = array(); +$audit_test_user_query_failure = false; +$audit_test_realm_query_failure = false; + +function api_plugin_user_realm_auth($filename = '') { + global $audit_test_realms; + + return !empty($audit_test_realms[$filename]); +} + +function db_fetch_cell_prepared($sql, $params = array()) { + global $audit_test_existing_users, $audit_test_user_query_failure; + + if ($audit_test_user_query_failure) { + return false; + } + + $user_id = (int) ($params[0] ?? 0); + + return in_array($user_id, $audit_test_existing_users, true) ? 1 : 0; +} + +function db_fetch_assoc_prepared($sql, $params = array()) { + global $audit_test_user_realms, $audit_test_realm_query_failure; + + if ($audit_test_realm_query_failure) { + return false; + } + + $user_id = (int) ($params[0] ?? 0); + $realm_ids = $audit_test_user_realms[$user_id] ?? array(); + + return array_map(function($realm_id) { + return array('realm_id' => $realm_id); + }, $realm_ids); +} + function audit_test_assert_same($expected, $actual, $message) { if ($expected !== $actual) { fwrite(STDERR, $message . PHP_EOL); @@ -11,6 +50,84 @@ function audit_test_assert_same($expected, $actual, $message) { } } +audit_test_assert_same(false, audit_user_is_admin(), 'Audit users must not be treated as audit administrators.'); +$audit_test_realms['audit_manage.php'] = true; +audit_test_assert_same(true, audit_user_is_admin(), 'Audit plugin administrators must be able to purge.'); +$audit_test_realms = array(); + +$verifier = audit_operation_verifier_for_request('user_admin.php', array( + 'action' => 'save', + 'id' => '4', + 'save_component_realm_perms' => '1', + 'section110' => 'on', + 'section106' => 'on' +)); +audit_test_assert_same( + array( + 'type' => 'user_realm_permissions', + 'target_user_id' => 4, + 'expected_realm_ids' => array(106, 110) + ), + $verifier, + 'User realm permission saves must capture a normalized post-condition verifier.' +); + +audit_test_assert_same( + null, + audit_operation_verifier_for_request('host.php', array('id' => '4')), + 'Unrelated requests must not receive a user realm verifier.' +); + +$invalid_verifier = audit_operation_verifier_for_request('user_admin.php', array( + 'id' => 'invalid', + 'save_component_realm_perms' => '1' +)); +audit_test_assert_same( + 'invalid', + $invalid_verifier['type'], + 'Invalid realm permission requests must not be verified as successful.' +); + +$audit_test_existing_users = array(4); +$audit_test_user_realms = array(4 => array(110, 106)); +audit_test_assert_same( + array('outcome' => 'success', 'reason' => 'realm_permissions_verified'), + audit_verify_operation($verifier), + 'Matching stored realm permissions must produce a verified success outcome.' +); + +$audit_test_user_realms = array(4 => array(106)); +audit_test_assert_same( + array('outcome' => 'failure', 'reason' => 'realm_permissions_mismatch'), + audit_verify_operation($verifier), + 'Mismatched stored realm permissions must produce a failure outcome.' +); + +$audit_test_existing_users = array(); +audit_test_assert_same( + array('outcome' => 'failure', 'reason' => 'target_user_not_found'), + audit_verify_operation($verifier), + 'A missing target user must produce a failure outcome.' +); + +$audit_test_existing_users = array(4); +$audit_test_user_query_failure = true; +audit_test_assert_same( + array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'), + audit_verify_operation($verifier), + 'A failed target-user query must preserve an unknown outcome.' +); +$audit_test_user_query_failure = false; + +$audit_test_existing_users = array(4); +$audit_test_realm_query_failure = true; +audit_test_assert_same( + array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'), + audit_verify_operation($verifier), + 'A failed verification query must preserve an unknown outcome.' +); +$audit_test_realm_query_failure = false; + $request = array( 'username' => 'operator', 'password' => 'top-secret',