From e53955807756350d76a2c6280675b589a4c62b99 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:10:02 -0400 Subject: [PATCH 1/9] feat: add authentication and session auditing - ingest Cacti user_log events with transactional deduplication - capture logout completion and authorization-denied events - add brute-force detection with atomic alert throttling - preserve deduplication state across audit-log purges - add authentication settings and upgrade handling - test Cacti 1.2.x and develop compatibility in CI - add behavioral coverage for races, retries, paging, and retention --- .github/workflows/code-quality.yml | 1 + .github/workflows/plugin-ci-workflow.yml | 91 +++- CHANGELOG.md | 13 + README.md | 48 +- audit_functions.php | 534 +++++++++++++++++- phpstan-baseline.neon | 48 +- setup.php | 159 ++++++ tests/auth_audit_test.php | 657 +++++++++++++++++++++++ tests/controller_security_test.php | 41 ++ 9 files changed, 1569 insertions(+), 23 deletions(-) create mode 100644 tests/auth_audit_test.php diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 0e9994a..4f28ea1 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -96,5 +96,6 @@ jobs: php tests/security_functions_test.php php tests/controller_security_test.php php tests/syslog_queue_test.php + php tests/auth_audit_test.php timeout 60 php tests/syslog_functions_test.php working-directory: cacti/plugins/audit diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index befe032..451d674 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -40,6 +40,7 @@ jobs: matrix: php: ['8.4'] os: [ubuntu-latest] + cacti_branch: ['1.2.x', 'develop'] services: mysql: @@ -57,14 +58,14 @@ jobs: --health-timeout=5s --health-retries=3 - name: PHP ${{ matrix.php }} Integration Test on ${{ matrix.os }} + name: PHP ${{ matrix.php }} Integration Test (Cacti ${{ matrix.cacti_branch }}) on ${{ matrix.os }} steps: - name: Checkout Cacti uses: actions/checkout@v7 with: repository: Cacti/cacti - ref: 1.2.x + ref: ${{ matrix.cacti_branch }} path: cacti - name: Checkout audit Plugin @@ -195,6 +196,39 @@ jobs: echo "Audit Syslog delivery queue table is missing" exit 1 fi + + AUTH_STATE_TABLE_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'cacti' + AND table_name = 'audit_user_log_state'; + ") + if [ "$AUTH_STATE_TABLE_COUNT" -ne 1 ]; then + echo "Authentication deduplication state table is missing" + exit 1 + fi + + AUTH_STATE_FK_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE constraint_schema = 'cacti' + AND table_name = 'audit_user_log_state' + AND constraint_type = 'FOREIGN KEY'; + ") + if [ "$AUTH_STATE_FK_COUNT" -ne 0 ]; then + echo "Authentication deduplication state must survive audit-log purges" + exit 1 + fi + + THROTTLE_SETTING_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM settings + WHERE name = 'audit_brute_force_last_alert'; + ") + if [ "$THROTTLE_SETTING_COUNT" -ne 1 ]; then + echo "Brute-force throttle setting was not initialized" + exit 1 + fi - name: Check PHP Syntax for Plugin run: | @@ -211,6 +245,7 @@ jobs: php tests/controller_security_test.php php tests/syslog_functions_test.php php tests/syslog_queue_test.php + php tests/auth_audit_test.php - name: Run Cacti Poller run: | @@ -241,6 +276,58 @@ jobs: cd ${{ github.workspace }}/cacti sudo php cli/add_device.php --description=test --ip=1 + - name: Exercise authentication ingestion + run: | + mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -e " + INSERT INTO user_log (username, user_id, time, result, ip) + VALUES ('audit_ci_login', 0, NOW(), 0, '192.0.2.10') + ON DUPLICATE KEY UPDATE result = VALUES(result), ip = VALUES(ip); + " + cd ${{ github.workspace }}/cacti + sudo php poller.php --poller=1 --force --debug + + AUTH_EVENT_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM audit_log + WHERE event_type = 'cacti.auth.login.failed' + AND target_id = 'audit_ci_login'; + ") + AUTH_STATE_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM audit_user_log_state + WHERE source_hash = SHA2( + CONCAT('audit_ci_login', '|', 0, '|', ( + SELECT time FROM user_log + WHERE username = 'audit_ci_login' AND user_id = 0 + ORDER BY time DESC LIMIT 1 + )), + 256 + ); + ") + + if [ "$AUTH_EVENT_COUNT" -ne 1 ] || [ "$AUTH_STATE_COUNT" -ne 1 ]; then + echo "Authentication ingestion did not atomically create one event and one state marker" + exit 1 + fi + + mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -e " + DELETE FROM audit_log + WHERE event_type = 'cacti.auth.login.failed' + AND target_id = 'audit_ci_login'; + " + sudo php poller.php --poller=1 --force --debug + + REPLAYED_EVENT_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM audit_log + WHERE event_type = 'cacti.auth.login.failed' + AND target_id = 'audit_ci_login'; + ") + if [ "$REPLAYED_EVENT_COUNT" -ne 0 ]; then + echo "Authentication source row was replayed after audit-log deletion" + exit 1 + fi + - name: check audit log entries run: | cd ${{ github.workspace }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d56164f..93d7823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ --- develop --- +* feature: Capture login failure, token, credentials-accepted, and authorization-denied events by polling the Cacti user_log table across all authentication methods +* feature: Ingest user_log every poller cycle with bounded anti-join paging and transactionally durable database-backed deduplication via audit_user_log_state +* feature: Apply the audit retention cutoff to every ingestion batch so historical rows are not replayed +* feature: Detect brute-force login patterns every poller cycle with atomically throttled critical alerts across concurrent pollers +* feature: Capture authorization-denied events through Cacti's custom_denied hook without taking over the denied-page rendering, with referer query strings redacted +* feature: Confirm session teardown through the logout_post_session_destroy hook, correlated with the existing pre-destroy logout event +* security: Record user_log result=1 as credentials_accepted with unknown outcome, not a confirmed login success +* security: Record ambiguous user_log result=3/user_id=0 and unsupported result codes as unknown rather than misclassifying them +* security: Restrict authentication auditing and brute-force detection settings to Audit Log Admin users and enforce authorization on save +* security: Gate the original logout event behind the authentication auditing master switch +* security: Persist authentication defaults on install and upgrade without overwriting existing administrator choices +* issue: Test integration CI against both Cacti 1.2.x and develop branches + * feature: Add standards-based remote Syslog delivery over UDP, TCP, and verified TLS * feature: Add RFC 5424 headers with RFC 5424, CEF, or compact JSON message formats * feature: Queue remote delivery in the poller with exponential backoff, dead-letter handling, health reporting, and audited admin actions diff --git a/README.md b/README.md index cbffe64..be2003f 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,52 @@ request. Matching state is recorded as `success` with outcome reason The plugin also audits access to its own event list, searches, event details, exports and purge operations. Logout and session-timeout events are captured -through Cacti's supported `logout_pre_session_destroy` hook. Database-level -changes, API activity and MFA events are outside the current Cacti 1.2.x scope. +through Cacti's supported `logout_pre_session_destroy` hook, and session +teardown is confirmed through the `logout_post_session_destroy` hook. + +Login failure, token (remember-me and 2FA), credentials-accepted, and +authorization-denied events are captured by polling Cacti's `user_log` table +from the poller and through the `custom_denied` hook. The `user_log` table is +the authoritative source across all Cacti authentication methods (local, LDAP, +basic, and domains) and is stable across the 1.2.x and develop branches, so +the plugin does not rely on the local-auth-only `login_process` hook. + +Cacti writes `user_log` `result = 1` before verifying that the account is +enabled, authorized for any realm, or has completed 2FA. The plugin therefore +records this as `cacti.auth.login.credentials_accepted` with +`operation_outcome = unknown`, not as a confirmed successful login. Cacti's +password-change inserts and the develop branch's failed-2FA inserts both write +`result = 3` with `user_id = 0`, so `user_log` alone cannot disambiguate them; +the plugin records these as `cacti.auth.password_change_or_2fa_failed` with +`operation_outcome = unknown`. Unsupported result codes are recorded as +`cacti.auth.login.unknown` with `operation_outcome = unknown`. + +Ingestion runs every poller cycle with a bounded workload (default 1000 rows +per cycle, configurable via `audit_user_log_batch_size`). Deduplication is +durable and database-backed: each processed `user_log` primary-key tuple is +recorded in an `audit_user_log_state` table as a deterministic SHA-256 hash, +so repeated and concurrent pollers cannot double-record the same source row. +Each bounded query selects recent source rows without a durable marker, so +failed inserts and late commits remain discoverable. The audit row and state +marker are committed in one transaction before external delivery; a +concurrent loser rolls back its duplicate audit row. State markers survive +audit-log purges and are retired only after their source rows fall outside +the configured retention window. + +Every ingestion query applies the audit retention cutoff +(`NOW() - audit_retention` days, or 90 days if retention is indefinite), so +arbitrary historical `user_log` rows are not replayed and stale records are +not exported. + +Brute-force detection runs every poller cycle and emits a +`cacti.auth.brute_force_suspected` critical event when failed logins exceed a +configurable threshold within a rolling window. Alert emission is atomically +throttled to one event per window via a conditional `UPDATE` on the settings +table, so concurrent pollers cannot emit duplicate alerts. The throttle marker +is only persisted after a confirmed audit insert. Authentication auditing and +brute-force detection settings are restricted to Audit Log Admin users. +Database-level changes and API activity remain outside the current Cacti 1.2.x +scope. ## Permissions diff --git a/audit_functions.php b/audit_functions.php index db65fe9..a37c1c3 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -681,7 +681,7 @@ function audit_record_event(string $event_type, array $options = []): int { $ip_address = $options['ip_address'] ?? (function_exists('get_client_addr') ? get_client_addr() : ''); $user_agent = $options['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? ''); - db_execute_prepared('INSERT INTO audit_log ( + $inserted = db_execute_prepared('INSERT INTO audit_log ( page, user_id, action, request_status, ip_address, user_agent, event_time, post, object_data, external_status, event_uuid, correlation_id, event_type, event_category, severity, actor_type, target_type, target_id, @@ -700,20 +700,46 @@ function audit_record_event(string $event_type, array $options = []): int { $options['duration_ms'] ?? 0, $details ]); - $id = db_fetch_insert_id(); + if (!$inserted) { + return 0; + } + + $id = (int) db_fetch_insert_id(); + + if ($id <= 0) { + return 0; + } + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); if (is_array($event)) { db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', [audit_event_integrity_hash($event), $id]); } - audit_deliver_external_event($id); - audit_enqueue_syslog_event($id); + + if (empty($options['defer_delivery'])) { + audit_deliver_external_event($id); + audit_enqueue_syslog_event($id); + } return $id; } function audit_logout_pre_session_destroy(): void { + // Stash the logging-out user identity and request correlation id so the + // post-destroy hook can confirm session teardown after $_SESSION is gone. + // This runs regardless of the auth-audit switch so the stash is available + // if the switch is toggled on between the two hooks (unlikely but safe). + audit_logout_stash([ + 'user_id' => (int) ($_SESSION['sess_user_id'] ?? 0), + 'correlation_id' => audit_request_correlation_id(), + 'reason' => get_nfilter_request_var('action', 'user') + ]); + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + $reason = get_nfilter_request_var('action', 'user'); $type = $reason == 'timeout' ? 'authentication.session.expired' : 'authentication.logout'; @@ -724,6 +750,464 @@ function audit_logout_pre_session_destroy(): void { ]); } +/** + * Per-request stash shared between the pre- and post-destroy logout hooks. + * + * @param array|null $set + * @return array + */ +function audit_logout_stash(?array $set = null): array { + static $stash = []; + + if (is_array($set)) { + $stash = $set; + } + + return $stash; +} + +function audit_logout_post_session_destroy(): void { + if (read_config_option('audit_enabled') != 'on') { + return; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + + $stash = audit_logout_stash(); + + if (empty($stash)) { + return; + } + + audit_record_event('authentication.logout.completed', [ + 'event_category' => 'authentication', + 'action' => 'logout_completed', + 'user_id' => $stash['user_id'] ?? 0, + 'correlation_id' => $stash['correlation_id'] ?? audit_request_correlation_id(), + 'operation_outcome' => 'success', + 'details' => [ + 'reason' => $stash['reason'] ?? 'user', + 'session_destroyed' => true + ] + ]); + + audit_logout_stash([]); +} + +/** + * Map a Cacti user_log result code to an audit event descriptor. + * + * Cacti writes user_log rows with result codes: + * 0 = Failed login + * 1 = Credentials accepted (written BEFORE enabled/realm/2FA checks) + * 2 = Success - Token (remember-me or 2FA) + * 3 = Password Change OR failed 2FA (user_id is omitted, defaulting to 0) + * + * Cacti writes result=1 before verifying that the account is enabled, + * authorized for any realm, or has completed 2FA, so it does not prove an + * authenticated session was established. It is recorded as credentials + * accepted with operation_outcome=unknown, not success. + * + * Cacti's password-change inserts and the develop branch's failed-2FA inserts + * both write result=3 with user_id=0, so user_log alone cannot disambiguate + * them; that combination is recorded as an ambiguous event with + * operation_outcome=unknown. No current Cacti path writes result=3 with + * user_id>0, but a future version may; that is treated defensively as a + * password change with outcome=unknown rather than claiming a confirmed + * password change. + * + * Any unsupported result code is recorded as an explicit unknown event rather + * than falling through to a password-change or 2FA event. + * + * @return array{event_type:string,severity:string,outcome:string,action:string,details:array} + */ +function audit_user_log_event_descriptor(int $result, int $user_id): array { + if ($result === 0) { + return [ + 'event_type' => 'cacti.auth.login.failed', + 'severity' => 'warning', + 'outcome' => 'failure', + 'action' => 'login_failed', + 'details' => [] + ]; + } + + if ($result === 1) { + return [ + 'event_type' => 'cacti.auth.login.credentials_accepted', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'credentials_accepted', + 'details' => [ + 'note' => __('Cacti records this outcome before verifying account enabled, realm authorization, or 2FA completion; a session may not have been established.', 'audit') + ] + ]; + } + + if ($result === 2) { + return [ + 'event_type' => 'cacti.auth.login.token', + 'severity' => 'info', + 'outcome' => 'success', + 'action' => 'login_token', + 'details' => [] + ]; + } + + if ($result === 3 && $user_id > 0) { + return [ + 'event_type' => 'cacti.auth.password.changed', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'password_changed', + 'details' => [ + 'note' => __('No current Cacti path writes this signature; recorded defensively as a possible password change with an unconfirmed outcome.', 'audit') + ] + ]; + } + + if ($result === 3) { + return [ + 'event_type' => 'cacti.auth.password_change_or_2fa_failed', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'password_change_or_2fa_failed', + 'details' => [ + 'ambiguous' => true, + 'note' => __('Cacti user_log result=3 with user_id=0 may be a password change or a failed 2FA challenge; the table cannot disambiguate.', 'audit') + ] + ]; + } + + // Unsupported result code: record explicitly as unknown rather than + // falling through to a password-change or 2FA event. + return [ + 'event_type' => 'cacti.auth.login.unknown', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'unknown_result', + 'details' => [ + 'unsupported_result_code' => $result + ] + ]; +} + +/** + * Compute a deterministic SHA-256 source identity for a user_log row. + */ +function audit_user_log_source_hash(string $username, int $user_id, string $time): string { + return hash('sha256', $username . '|' . $user_id . '|' . $time); +} + +/** + * Poll Cacti's user_log table for new login/logout/token/password-change + * outcomes and record them as audit events. The user_log table is the + * authoritative source across all auth methods (local, LDAP, basic, domains) + * and is stable across the 1.2.x and develop branches, so this avoids + * relying on the local-auth-only login_process hook. + * + * Deduplication is durable and database-backed: each processed user_log + * primary-key tuple (username, user_id, time) is recorded in + * audit_user_log_state as a deterministic SHA-256 hash. The audit event and + * state marker are committed atomically; a concurrent loser rolls back its + * duplicate event before any external delivery occurs. + * + * Each cycle selects a bounded batch of recent user_log rows that have no + * state marker. This anti-join approach keeps failed inserts and late commits + * discoverable instead of advancing a high-water cursor past them. The + * retention cutoff prevents arbitrary historical backfill. + */ +function audit_poll_user_log(): void { + if (read_config_option('audit_enabled') != 'on') { + return; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + + if (!function_exists('db_table_exists') || !db_table_exists('user_log')) { + return; + } + + if (!db_table_exists('audit_user_log_state')) { + return; + } + + $batch_size = (int) read_config_option('audit_user_log_batch_size'); + + if ($batch_size < 1) { + $batch_size = 1000; + } elseif ($batch_size > 5000) { + $batch_size = 5000; + } + + $retention = (int) read_config_option('audit_retention'); + + if ($retention <= 0) { + $retention = 90; + } + + $cutoff = audit_retention_cutoff($retention)->format('Y-m-d H:i:s'); + $rows = db_fetch_assoc_prepared( + 'SELECT ul.username, ul.user_id, ul.result, ul.ip, ul.time + FROM user_log AS ul + WHERE ul.time > ? + AND NOT EXISTS ( + SELECT 1 + FROM audit_user_log_state AS auls + WHERE auls.source_hash = SHA2( + CONCAT(ul.username, "|", ul.user_id, "|", ul.time), + 256 + ) + ) + ORDER BY ul.time ASC, ul.username ASC, ul.user_id ASC + LIMIT ?', + [$cutoff, $batch_size] + ); + + if (!is_array($rows) || cacti_sizeof($rows) === 0) { + return; + } + + $now_utc = audit_utc_time(); + + foreach ($rows as $row) { + $result = (int) $row['result']; + $user_id = (int) $row['user_id']; + $time = (string) $row['time']; + $username = (string) $row['username']; + + $source_hash = audit_user_log_source_hash($username, $user_id, $time); + $source_key = $username . '|' . $user_id . '|' . $time; + + if (!db_execute_prepared('START TRANSACTION')) { + continue; + } + + $descriptor = audit_user_log_event_descriptor($result, $user_id); + + $audit_id = audit_record_event($descriptor['event_type'], [ + 'event_category' => 'authentication', + 'action' => $descriptor['action'], + 'severity' => $descriptor['severity'], + 'operation_outcome' => $descriptor['outcome'], + 'actor_type' => $user_id > 0 ? 'user' : 'anonymous', + 'target_type' => 'user_account', + 'target_id' => $user_id > 0 ? (string) $user_id : $username, + 'ip_address' => (string) ($row['ip'] ?? ''), + 'user_agent' => '', + 'page' => 'user_log.php', + 'event_time' => $time, + 'defer_delivery' => true, + 'details' => [ + 'username' => $username, + 'result_code' => $result, + 'source_table' => 'user_log', + 'descriptor' => $descriptor['details'] + ] + ]); + + if ($audit_id <= 0) { + db_execute_prepared('ROLLBACK'); + + continue; + } + + $state_inserted = db_execute_prepared( + 'INSERT IGNORE INTO audit_user_log_state + (source_hash, source_key, source_time, audit_id, processed_time) + VALUES (?, ?, ?, ?, ?)', + [$source_hash, $source_key, $time, $audit_id, $now_utc] + ); + + if (!$state_inserted || db_affected_rows() !== 1) { + db_execute_prepared('ROLLBACK'); + + continue; + } + + if (!db_execute_prepared('COMMIT')) { + db_execute_prepared('ROLLBACK'); + + continue; + } + + audit_deliver_external_event($audit_id); + audit_enqueue_syslog_event($audit_id); + } +} + +/** + * Detect brute-force login patterns by counting failed user_log entries + * within a rolling window. Emits a single critical audit event per window + * to avoid alert flooding. + */ +function audit_detect_brute_force(): void { + if (read_config_option('audit_enabled') != 'on') { + return; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + + if (read_config_option('audit_brute_force_enabled') != 'on') { + return; + } + + if (!function_exists('db_table_exists') || !db_table_exists('user_log')) { + return; + } + + $window = (int) read_config_option('audit_brute_force_window_minutes'); + + if ($window < 1) { + $window = 5; + } elseif ($window > 1440) { + $window = 1440; + } + + $threshold = (int) read_config_option('audit_brute_force_threshold'); + + if ($threshold < 1) { + $threshold = 10; + } elseif ($threshold > 1000) { + $threshold = 1000; + } + + $count = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) + FROM user_log + WHERE result = 0 + AND time >= DATE_SUB(NOW(), INTERVAL ? MINUTE)', + [$window] + ); + + if ($count < $threshold) { + return; + } + + // Atomically claim the alert slot so two concurrent pollers cannot both + // emit for the same window. The conditional UPDATE only succeeds if the + // last alert is empty or older than the window; the affected-row count + // proves ownership. The settings row is created defensively first so a + // fresh install can participate in the same atomic claim. + $now = gmdate('Y-m-d H:i:s'); + + $initialized = db_execute_prepared( + 'INSERT IGNORE INTO settings (name, value) VALUES (?, ?)', + ['audit_brute_force_last_alert', ''] + ); + + if (!$initialized) { + return; + } + + $claimed = db_execute_prepared( + "UPDATE settings + SET value = ? + WHERE name = 'audit_brute_force_last_alert' + AND (value = '' OR value = '0' + OR STR_TO_DATE(value, '%Y-%m-%d %H:%i:%s') < DATE_SUB(?, INTERVAL ? MINUTE))", + [$now, $now, $window] + ); + + if (!$claimed || db_affected_rows() < 1) { + return; + } + + $audit_id = audit_record_event('cacti.auth.brute_force_suspected', [ + 'event_category' => 'authentication', + 'action' => 'brute_force_suspected', + 'severity' => 'critical', + 'operation_outcome' => 'failure', + 'actor_type' => 'system', + 'target_type' => 'authentication', + 'details' => [ + 'failed_attempts' => $count, + 'window_minutes' => $window, + 'threshold' => $threshold + ] + ]); + + // Keep the claimed timestamp after a confirmed successful audit insert. + // If the insert failed, release the slot so the next poller can retry. + if ($audit_id > 0) { + set_config_option('audit_brute_force_last_alert', $now); + } else { + set_config_option('audit_brute_force_last_alert', ''); + } +} + +/** + * Hook handler for Cacti's custom_denied hook. Records an authorization- + * denied event and returns the input mode unchanged so Cacti continues + * rendering its default permission-denied page. + * + * @param mixed $mode + * @return mixed + */ +function audit_custom_denied(mixed $mode): mixed { + if (read_config_option('audit_enabled') != 'on') { + return $mode; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return $mode; + } + + $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + $user_id = (int) ($_SESSION['sess_user_id'] ?? 0); + + // Record only the referer origin and path; strip the query string to + // avoid leaking tokens, reset hashes, OAuth state, or session + // identifiers into the audit log and external syslog consumers. + $safe_referer = ''; + + if ($referer !== '') { + $parsed = parse_url((string) $referer); + $safe_ref = ''; + + if (is_array($parsed)) { + if (isset($parsed['scheme']) && isset($parsed['host'])) { + $safe_ref = $parsed['scheme'] . '://' . $parsed['host']; + + if (isset($parsed['port'])) { + $safe_ref .= ':' . $parsed['port']; + } + } + + if (isset($parsed['path'])) { + $safe_ref .= $parsed['path']; + } + } + + $safe_referer = $safe_ref !== '' ? $safe_ref : '[unparseable]'; + } + + audit_record_event('cacti.auth.authorization.denied', [ + 'event_category' => 'authentication', + 'action' => 'authorization_denied', + 'severity' => 'warning', + 'operation_outcome' => 'failure', + 'actor_type' => $user_id > 0 ? 'user' : 'anonymous', + 'target_type' => 'page', + 'target_id' => $page, + 'page' => $page, + 'details' => [ + 'requested_page' => $page, + 'referer_origin' => $safe_referer, + 'referer_redacted' => $referer !== $safe_referer + ] + ]); + + return $mode; +} + function audit_enforce_syslog_settings_request(): void { $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); $method = $_SERVER['REQUEST_METHOD'] ?? ''; @@ -740,28 +1224,46 @@ function audit_enforce_syslog_settings_request(): void { } $has_syslog_fields = false; + $has_auth_fields = false; foreach ($post as $name => $value) { - if (strpos((string) $name, 'audit_syslog_') === 0) { - $has_syslog_fields = true; + $name = (string) $name; - break; + if (strpos($name, 'audit_syslog_') === 0) { + $has_syslog_fields = true; + } elseif (strpos($name, 'audit_auth_') === 0 || strpos($name, 'audit_brute_force_') === 0) { + $has_auth_fields = true; } } - if (!$has_syslog_fields) { + if (!$has_syslog_fields && !$has_auth_fields) { return; } if (!audit_user_is_admin()) { - audit_record_event('audit.syslog.configuration.denied', [ - 'event_category' => 'audit', - 'severity' => 'warning', - 'action' => 'save', - 'target_type' => 'syslog_configuration', - 'operation_outcome' => 'failure', - 'outcome_reason' => 'audit_admin_required' - ]); + // Preserve the syslog-specific denied event when syslog fields are + // part of the unauthorized save; use a generic audit-configuration + // event when only authentication/brute-force fields are present. + if ($has_syslog_fields) { + audit_record_event('audit.syslog.configuration.denied', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'save', + 'target_type' => 'syslog_configuration', + 'operation_outcome' => 'failure', + 'outcome_reason' => 'audit_admin_required' + ]); + } else { + audit_record_event('audit.configuration.denied', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'save', + 'target_type' => 'audit_configuration', + 'operation_outcome' => 'failure', + 'outcome_reason' => 'audit_admin_required' + ]); + } + http_response_code(403); exit; } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 782fd76..2170645 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -15,7 +15,7 @@ parameters: - message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' identifier: function.resultUnused - count: 1 + count: 3 path: audit_functions.php - @@ -39,7 +39,7 @@ parameters: - message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' identifier: function.resultUnused - count: 1 + count: 2 path: setup.php - @@ -90,6 +90,48 @@ parameters: count: 1 path: tests/Security/SetupStructureTest.php + - + message: '#^Call to function in_array\(\) with arguments ''old'', array\{\} and true will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: tests/auth_audit_test.php + + - + message: '#^Empty array passed to foreach\.$#' + identifier: foreach.emptyArray + count: 1 + path: tests/auth_audit_test.php + + - + message: '#^Offset ''audit_brute_force…'' on array\{audit_brute_force_last_alert\: ''''\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: tests/auth_audit_test.php + + - + message: '#^Offset 0 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 9 + path: tests/auth_audit_test.php + + - + message: '#^Offset 1 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 2 + path: tests/auth_audit_test.php + + - + message: '#^Offset 3 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 2 + path: tests/auth_audit_test.php + + - + message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|false given\.$#' + identifier: argument.type + count: 4 + path: tests/auth_audit_test.php + - message: '#^Function __\(\) has no return type specified\.$#' identifier: missingType.return @@ -579,7 +621,7 @@ parameters: - message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|false given\.$#' identifier: argument.type - count: 11 + count: 12 path: tests/controller_security_test.php - diff --git a/setup.php b/setup.php index 41dadc8..3f04487 100644 --- a/setup.php +++ b/setup.php @@ -33,6 +33,8 @@ function plugin_audit_install(): void { api_plugin_register_hook('audit', 'utilities_array', 'audit_utilities_array', 'setup.php'); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php'); api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php'); + api_plugin_register_hook('audit', 'logout_post_session_destroy', 'audit_logout_post_session_destroy', 'audit_functions.php'); + api_plugin_register_hook('audit', 'custom_denied', 'audit_custom_denied', 'audit_functions.php'); // hook for table replication api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php'); @@ -40,6 +42,35 @@ function plugin_audit_install(): void { audit_setup_realms(true); audit_setup_table(); + audit_persist_auth_defaults(); +} + +/** + * Persist authentication auditing defaults without overwriting existing + * administrator choices. Called on fresh install and upgrade so that + * ordinary-user logout and authorization-denied hooks work with the + * advertised default even though the configuration controls remain hidden + * from non-Audit-Admin users. + */ +function audit_persist_auth_defaults(): void { + $defaults = [ + 'audit_auth_log_enabled' => 'on', + 'audit_brute_force_enabled' => 'on', + 'audit_brute_force_window_minutes' => '5', + 'audit_brute_force_threshold' => '10', + 'audit_brute_force_last_alert' => '' + ]; + + foreach ($defaults as $name => $value) { + $exists = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) FROM settings WHERE name = ?', + [$name] + ); + + if ($exists === 0) { + set_config_option($name, $value); + } + } } function audit_setup_realms(bool $grant_installing_user = false): void { @@ -105,6 +136,7 @@ function audit_remove_deprecated_realms(): void { } function plugin_audit_uninstall(): bool { + db_execute('DROP TABLE IF EXISTS audit_user_log_state'); db_execute('DROP TABLE IF EXISTS audit_syslog_delivery'); db_execute('DROP TABLE IF EXISTS audit_log'); @@ -170,6 +202,8 @@ function audit_check_upgrade(): void { db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status'); audit_upgrade_event_schema(); audit_setup_syslog_table(); + audit_setup_user_log_state_table(); + audit_persist_auth_defaults(); audit_setup_realms(); audit_remove_deprecated_realms(); @@ -190,6 +224,8 @@ function audit_check_upgrade(): void { api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php', 1); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php', 1); api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php', 1); + api_plugin_register_hook('audit', 'logout_post_session_destroy', 'audit_logout_post_session_destroy', 'audit_functions.php', 1); + api_plugin_register_hook('audit', 'custom_denied', 'audit_custom_denied', 'audit_functions.php', 1); } } @@ -242,6 +278,9 @@ function audit_replicate_out(array $data): array { db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data", true, $rcnn_id); db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status', true, $rcnn_id); audit_upgrade_event_schema($rcnn_id); + + // Replicate and migrate durable user_log deduplication state. + audit_setup_user_log_state_table($rcnn_id); } return $data; @@ -251,6 +290,19 @@ function audit_poller_bottom(): void { audit_retry_external_logs(); audit_process_syslog_queue(); + // Brute-force detection runs every poller cycle so short bursts are + // caught in near-real-time. Only alert emission is throttled inside the + // function via audit_brute_force_last_alert. + audit_detect_brute_force(); + + // Authentication events are captured by polling Cacti's user_log table, + // which is authoritative across all auth methods (local, LDAP, basic, + // domains) and stable across the 1.2.x and develop branches. Ingestion + // runs every poller cycle with a bounded workload so login failures and + // authorization events appear promptly; the deduplication table prevents + // duplicate events across repeated and concurrent pollers. + audit_poll_user_log(); + $last_check = read_config_option('audit_last_check'); $now = gmdate('Y-m-d'); @@ -271,6 +323,16 @@ function audit_poller_bottom(): void { [$cutoff->format('Y-m-d H:i:s')]); $rows = db_affected_rows(); cacti_log('NOTE: Purged ' . $rows . ' Audit Log Records from Cacti', false, 'POLLER'); + + // Deduplication state intentionally survives audit_log deletion so + // recent user_log rows are not imported again. Markers older than + // this cutoff can be removed safely because polling never selects + // source rows outside the same retention window. + if (db_table_exists('audit_user_log_state')) { + db_execute_prepared('DELETE FROM audit_user_log_state + WHERE source_time < ?', + [$cutoff->format('Y-m-d H:i:s')]); + } } } @@ -329,10 +391,64 @@ function audit_setup_table(): bool { COMMENT='Audit Log for all GUI activities'"); audit_setup_syslog_table(); + audit_setup_user_log_state_table(); return true; } +/** + * Durable, database-backed deduplication table for user_log ingestion. + * + * Each processed user_log primary-key tuple (username, user_id, time) is + * recorded as a deterministic SHA-256 hash so repeated and concurrent + * pollers cannot double-record the same source row. audit_id is deliberately + * not a foreign key: deduplication state must survive audit-log retention and + * manual purges, otherwise recent user_log rows would be imported again. + */ +function audit_setup_user_log_state_table(mixed $cnn_id = false): void { + db_execute("CREATE TABLE IF NOT EXISTS `audit_user_log_state` ( + `source_hash` char(64) NOT NULL, + `source_key` varchar(160) NOT NULL DEFAULT '', + `source_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `audit_id` bigint(20) unsigned NOT NULL, + `processed_time` datetime(6) NOT NULL, + PRIMARY KEY (`source_hash`), + KEY `source_time_key` (`source_time`, `source_key`)) + ENGINE=InnoDB + COMMENT='Durable deduplication state for user_log ingestion'", + true, + $cnn_id + ); + + $has_foreign_key = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ? + AND CONSTRAINT_TYPE = ?', + ['audit_user_log_state', 'fk_audit_user_log_state_event', 'FOREIGN KEY'], + '', + true, + $cnn_id + ); + + if ($has_foreign_key > 0) { + db_execute( + 'ALTER TABLE audit_user_log_state + DROP FOREIGN KEY fk_audit_user_log_state_event', + true, + $cnn_id + ); + } + + db_execute('ALTER TABLE audit_user_log_state + ADD COLUMN IF NOT EXISTS source_key varchar(160) NOT NULL DEFAULT "" AFTER source_hash', + true, + $cnn_id + ); +} + function audit_setup_syslog_table(): void { db_execute("CREATE TABLE IF NOT EXISTS `audit_syslog_delivery` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, @@ -555,6 +671,49 @@ function audit_config_settings(): void { ]; if (php_sapi_name() === 'cli' || audit_user_is_admin()) { + $auth_settings = [ + 'audit_auth_header' => [ + 'friendly_name' => __('Authentication Auditing', 'audit'), + 'method' => 'spacer', + ], + 'audit_auth_log_enabled' => [ + 'friendly_name' => __('Enable Authentication Auditing', 'audit'), + 'description' => __('Check this box to capture login, logout, token, password-change, and authorization-denied events by polling the Cacti user_log table and supported hooks.', 'audit'), + 'method' => 'checkbox', + 'default' => 'on' + ], + 'audit_brute_force_enabled' => [ + 'friendly_name' => __('Enable Brute-force Detection', 'audit'), + 'description' => __('Check this box to emit a critical audit event when failed logins exceed the threshold within the configured window.', 'audit'), + 'method' => 'checkbox', + 'default' => 'on' + ], + 'audit_brute_force_window_minutes' => [ + 'friendly_name' => __('Brute-force Window (minutes)', 'audit'), + 'description' => __('Rolling window in minutes, from 1 through 1440, used to count failed logins.', 'audit'), + 'method' => 'textbox', + 'default' => '5', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_brute_force_threshold' => [ + 'friendly_name' => __('Brute-force Threshold', 'audit'), + 'description' => __('Number of failed logins within the window, from 1 through 1000, that triggers a brute-force alert.', 'audit'), + 'method' => 'textbox', + 'default' => '10', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_user_log_batch_size' => [ + 'friendly_name' => __('User Log Ingestion Batch Size', 'audit'), + 'description' => __('Maximum user_log rows ingested per poller cycle, from 1 through 5000. Larger batches process backlogs faster but increase poller runtime.', 'audit'), + 'method' => 'textbox', + 'default' => '1000', + 'max_length' => '4', + 'size' => '8' + ], + ]; + $facility_options = []; foreach (audit_syslog_facilities() as $facility => $code) { diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php new file mode 100644 index 0000000..f043aa5 --- /dev/null +++ b/tests/auth_audit_test.php @@ -0,0 +1,657 @@ + 'on', + 'audit_auth_log_enabled' => 'on', + 'audit_brute_force_enabled' => 'on', + 'audit_brute_force_window_minutes' => '5', + 'audit_brute_force_threshold' => '10', + 'audit_brute_force_last_alert' => '', + 'audit_user_log_batch_size' => '1000', + 'audit_retention' => '90' +]; + +$audit_auth_recorded_events = []; +$audit_auth_user_log_rows = []; +$audit_auth_state_rows = []; +$audit_auth_failed_count = 0; +$audit_auth_set_options = []; +$audit_auth_settings_rows = []; +$audit_auth_insert_fails = false; +$audit_auth_fail_usernames = []; +$audit_auth_state_conflict = false; +$audit_auth_affected_rows = 0; +$audit_auth_transaction = null; + +function read_config_option(string $name, bool $force = false): string { + global $audit_auth_config, $audit_auth_settings_rows; + + if (isset($audit_auth_settings_rows[$name])) { + return (string) $audit_auth_settings_rows[$name]; + } + + return $audit_auth_config[$name] ?? ''; +} + +function set_config_option(string $name, string $value): void { + global $audit_auth_set_options, $audit_auth_config, $audit_auth_settings_rows; + $audit_auth_config[$name] = $value; + $audit_auth_settings_rows[$name] = $value; + $audit_auth_set_options[$name][] = $value; +} + +function db_execute_prepared(string $sql, array $params = []): bool { + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction; + + if (strpos($sql, 'START TRANSACTION') !== false) { + $audit_auth_transaction = [ + 'events' => $audit_auth_recorded_events, + 'state' => $audit_auth_state_rows + ]; + + return true; + } + + if (strpos($sql, 'ROLLBACK') !== false) { + if (is_array($audit_auth_transaction)) { + $audit_auth_recorded_events = $audit_auth_transaction['events']; + $audit_auth_state_rows = $audit_auth_transaction['state']; + } + + $audit_auth_transaction = null; + + return true; + } + + if (strpos($sql, 'COMMIT') !== false) { + $audit_auth_transaction = null; + + return true; + } + + if (strpos($sql, 'INSERT INTO audit_log') !== false) { + $details = json_decode((string) ($params[24] ?? ''), true); + $username = is_array($details) ? (string) ($details['username'] ?? '') : ''; + + if ($audit_auth_insert_fails || in_array($username, $audit_auth_fail_usernames, true)) { + $audit_auth_affected_rows = 0; + + return false; + } + $audit_auth_recorded_events[] = ['sql' => $sql, 'params' => $params]; + $audit_auth_affected_rows = 1; + + return true; + } + + if (strpos($sql, 'INSERT IGNORE INTO audit_user_log_state') !== false) { + $hash = $params[0]; + + if ($audit_auth_state_conflict || isset($audit_auth_state_rows[$hash])) { + $audit_auth_affected_rows = 0; + } else { + $audit_auth_state_rows[$hash] = [ + 'source_hash' => $hash, + 'source_key' => $params[1], + 'source_time' => $params[2], + 'audit_id' => $params[3], + 'processed_time' => $params[4] + ]; + $audit_auth_affected_rows = 1; + } + + return true; + } + + if (strpos($sql, 'INSERT IGNORE INTO settings') !== false) { + $name = (string) $params[0]; + + if (!array_key_exists($name, $audit_auth_settings_rows)) { + $audit_auth_settings_rows[$name] = (string) $params[1]; + $audit_auth_affected_rows = 1; + } else { + $audit_auth_affected_rows = 0; + } + + return true; + } + + if (strpos($sql, 'UPDATE settings') !== false && strpos($sql, 'audit_brute_force_last_alert') !== false) { + $name = 'audit_brute_force_last_alert'; + $current = $audit_auth_settings_rows[$name] ?? ''; + $now = $params[0]; + $window = $params[2]; + $should_update = ($current === '' || $current === '0'); + + if (!$should_update && $current !== '') { + $ts = strtotime($current); + $now_ts = strtotime($now); + $should_update = ($ts !== false && $now_ts !== false && ($now_ts - $ts) >= ($window * 60)); + } + + if ($should_update) { + $audit_auth_settings_rows[$name] = $now; + $audit_auth_affected_rows = 1; + + return true; + } + + $audit_auth_affected_rows = 0; + + return true; + } + + if (strpos($sql, 'DELETE FROM audit_user_log_state') !== false) { + $audit_auth_state_rows = []; + + return true; + } + + return true; +} + +function db_fetch_insert_id(): int { + global $audit_auth_recorded_events, $audit_auth_insert_fails; + + if ($audit_auth_insert_fails) { + return 0; + } + + return count($audit_auth_recorded_events); +} + +function db_fetch_row_prepared(string $sql, array $params = []): array { + global $audit_auth_state_rows; + + if (strpos($sql, 'MAX(source_time)') !== false) { + if (empty($audit_auth_state_rows)) { + return []; + } + + $max_time = ''; + $max_hash = ''; + + foreach ($audit_auth_state_rows as $row) { + if ($row['source_time'] > $max_time || ($row['source_time'] === $max_time && ($row['source_key'] ?? '') > $max_hash)) { + $max_time = $row['source_time']; + $max_hash = $row['source_key'] ?? ''; + } + } + + return ['max_time' => $max_time, 'max_key' => $max_hash]; + } + + return []; +} + +/** + * SQL-interpreting stub: filters user_log rows by retention and durable + * deduplication state, orders by (time, username, user_id), and applies LIMIT. + */ +function db_fetch_assoc_prepared(string $sql, array $params = []): array { + global $audit_auth_user_log_rows, $audit_auth_state_rows; + + if (strpos($sql, 'FROM user_log') === false) { + return []; + } + + $cutoff = (string) ($params[0] ?? ''); + $filtered = []; + + foreach ($audit_auth_user_log_rows as $row) { + $time = (string) $row['time']; + $hash = audit_user_log_source_hash( + (string) $row['username'], + (int) $row['user_id'], + $time + ); + + if ($time > $cutoff && !isset($audit_auth_state_rows[$hash])) { + $filtered[] = $row; + } + } + + usort($filtered, function ($a, $b) { + if ($a['time'] !== $b['time']) { + return $a['time'] <=> $b['time']; + } + + if ($a['username'] !== $b['username']) { + return $a['username'] <=> $b['username']; + } + + return $a['user_id'] <=> $b['user_id']; + }); + + $limit_idx = count($params) - 1; + $limit = (int) ($params[$limit_idx] ?? 1000); + + return array_slice($filtered, 0, $limit); +} + +function db_fetch_cell_prepared(string $sql, array $params = []): int|string { + global $audit_auth_failed_count, $audit_auth_state_rows; + + if (strpos($sql, 'COUNT(*)') !== false && strpos($sql, 'result = 0') !== false) { + return $audit_auth_failed_count; + } + + if (strpos($sql, 'SELECT 1 FROM audit_user_log_state') !== false) { + $hash = $params[0] ?? ''; + + return isset($audit_auth_state_rows[$hash]) ? 1 : 0; + } + + return ''; +} + +function db_affected_rows(): int { + global $audit_auth_affected_rows; + + return $audit_auth_affected_rows; +} + +function db_table_exists(string $table): bool { + return in_array($table, ['user_log', 'audit_user_log_state'], true); +} + +function cacti_sizeof(array|bool $array): int { + return is_array($array) ? count($array) : 0; +} + +function get_nfilter_request_var(string $name, mixed $default = null): mixed { + return $_REQUEST[$name] ?? $default; +} + +function get_request_var(string $name): mixed { + return $_REQUEST[$name] ?? ''; +} + +function api_plugin_user_realm_auth(string $filename = ''): bool { + return false; +} + +function html_escape(mixed $string): string { + return htmlspecialchars((string) $string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); +} + +function __(string $text, string $domain = ''): string { + return $text; +} + +function cacti_log(string $message, bool $also_print = false, string $log_type = '', int $level = 0): void { +} + +function audit_test_assert_same(mixed $expected, mixed $actual, string $message): void { + if ($expected !== $actual) { + fwrite(STDERR, $message . PHP_EOL); + fwrite(STDERR, 'Expected: ' . var_export($expected, true) . PHP_EOL); + fwrite(STDERR, 'Actual: ' . var_export($actual, true) . PHP_EOL); + exit(1); + } +} + +function audit_test_assert_true(bool $condition, string $message): void { + if (!$condition) { + fwrite(STDERR, $message . PHP_EOL); + exit(1); + } +} + +function audit_test_reset_state(): void { + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_set_options, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction; + $audit_auth_recorded_events = []; + $audit_auth_state_rows = []; + $audit_auth_set_options = []; + $audit_auth_settings_rows = []; + $audit_auth_insert_fails = false; + $audit_auth_fail_usernames = []; + $audit_auth_state_conflict = false; + $audit_auth_affected_rows = 0; + $audit_auth_transaction = null; +} + +// --------------------------------------------------------------------------- +// 1. Result-code mapping (audit_user_log_event_descriptor) +// --------------------------------------------------------------------------- + +audit_test_assert_same('cacti.auth.login.failed', audit_user_log_event_descriptor(0, 0)['event_type'], 'Failed logins must map to a login failed event.'); +audit_test_assert_same('failure', audit_user_log_event_descriptor(0, 0)['outcome'], 'Failed logins must be a failure outcome.'); + +// result=1: credentials accepted, NOT success (Cacti writes before checks). +audit_test_assert_same('cacti.auth.login.credentials_accepted', audit_user_log_event_descriptor(1, 5)['event_type'], 'result=1 must be credentials_accepted, not login.success.'); +audit_test_assert_same('unknown', audit_user_log_event_descriptor(1, 5)['outcome'], 'result=1 must carry an unknown outcome, not success.'); + +audit_test_assert_same('cacti.auth.login.token', audit_user_log_event_descriptor(2, 5)['event_type'], 'Token success must map to a login token event.'); +audit_test_assert_same('success', audit_user_log_event_descriptor(2, 5)['outcome'], 'Token success must be a success outcome.'); + +// result=3/user_id>0: defensive, unknown outcome (no false success). +audit_test_assert_same('cacti.auth.password.changed', audit_user_log_event_descriptor(3, 5)['event_type'], 'result=3/user_id>0 must map to password.changed.'); +audit_test_assert_same('unknown', audit_user_log_event_descriptor(3, 5)['outcome'], 'result=3/user_id>0 must carry unknown, not a confirmed success.'); + +// result=3/user_id=0: ambiguous. +$ambiguous = audit_user_log_event_descriptor(3, 0); +audit_test_assert_same('cacti.auth.password_change_or_2fa_failed', $ambiguous['event_type'], 'result=3/user_id=0 must map to the ambiguous event type.'); +audit_test_assert_same('unknown', $ambiguous['outcome'], 'The ambiguous event must carry an unknown outcome.'); +audit_test_assert_true(isset($ambiguous['details']['ambiguous']), 'The ambiguous event must flag itself as ambiguous.'); + +// Unsupported result code: explicit unknown, not a fallthrough. +$unknown = audit_user_log_event_descriptor(99, 5); +audit_test_assert_same('cacti.auth.login.unknown', $unknown['event_type'], 'Unsupported result codes must map to an explicit unknown event.'); +audit_test_assert_same('unknown', $unknown['outcome'], 'Unsupported result codes must carry an unknown outcome.'); +audit_test_assert_same(99, $unknown['details']['unsupported_result_code'], 'The unsupported code must be recorded in details.'); + +// --------------------------------------------------------------------------- +// 2. audit_poll_user_log() records one event per new user_log row +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'alice', 'user_id' => 5, 'result' => 1, 'ip' => '10.0.0.1', 'time' => '2026-07-25 10:00:01'], + ['username' => 'bob', 'user_id' => 0, 'result' => 0, 'ip' => '10.0.0.2', 'time' => '2026-07-25 10:00:02'], + ['username' => 'carol', 'user_id' => 7, 'result' => 2, 'ip' => '10.0.0.3', 'time' => '2026-07-25 10:00:03'], + ['username' => 'dave', 'user_id' => 0, 'result' => 3, 'ip' => '10.0.0.4', 'time' => '2026-07-25 10:00:04'] +]; + +audit_poll_user_log(); + +audit_test_assert_same(4, count($audit_auth_recorded_events), 'audit_poll_user_log() must record one event per new user_log row.'); +audit_test_assert_same('cacti.auth.login.credentials_accepted', $audit_auth_recorded_events[0]['params'][12], 'First event must be credentials_accepted for result=1.'); +audit_test_assert_same('warning', $audit_auth_recorded_events[1]['params'][14], 'Failed login must carry warning severity.'); +audit_test_assert_same('failure', $audit_auth_recorded_events[1]['params'][18], 'Failed login must carry failure outcome.'); +audit_test_assert_same('cacti.auth.password_change_or_2fa_failed', $audit_auth_recorded_events[3]['params'][12], 'result=3/user_id=0 must map to the ambiguous event.'); +audit_test_assert_same('unknown', $audit_auth_recorded_events[3]['params'][18], 'The ambiguous row must carry unknown outcome.'); +audit_test_assert_same(4, count($audit_auth_state_rows), 'Four deduplication state rows must be written.'); + +// Re-polling must not double-record (deduplication via state table). +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Re-polling after durable state is recorded must not produce duplicates.'); + +// Disabling auth auditing must skip polling. +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +$audit_auth_user_log_rows = [ + ['username' => 'eve', 'user_id' => 9, 'result' => 1, 'ip' => '10.0.0.5', 'time' => '2026-07-25 11:00:00'] +]; +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'audit_poll_user_log() must be gated by audit_auth_log_enabled.'); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 3. More than 1,000 rows sharing one timestamp: bounded anti-join paging +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_config['audit_user_log_batch_size'] = '1000'; +$audit_auth_user_log_rows = []; + +for ($i = 0; $i < 1500; $i++) { + $audit_auth_user_log_rows[] = [ + 'username' => sprintf('user%04d', $i), + 'user_id' => $i + 1, + 'result' => 0, + 'ip' => '10.0.0.10', + 'time' => '2026-07-25 12:00:00' + ]; +} + +audit_poll_user_log(); +audit_test_assert_same(1000, count($audit_auth_recorded_events), 'First cycle must process exactly the batch size (1000) rows, not all 1500.'); +audit_test_assert_same(1000, count($audit_auth_state_rows), '1000 state rows must be written after the first cycle.'); + +// Second cycle excludes durable markers and processes the remaining 500. +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(500, count($audit_auth_recorded_events), 'Second cycle must process the remaining 500 rows via durable-state exclusion.'); +audit_test_assert_same(1500, count($audit_auth_state_rows), 'All 1500 state rows must be written after two cycles.'); + +// Third cycle: nothing left. +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Third cycle must find no new rows.'); + +// --------------------------------------------------------------------------- +// 4. Multiple timestamp pages +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'a', 'user_id' => 1, 'result' => 1, 'ip' => '10.0.0.1', 'time' => '2026-07-25 13:00:00'], + ['username' => 'b', 'user_id' => 2, 'result' => 0, 'ip' => '10.0.0.2', 'time' => '2026-07-25 13:00:01'], + ['username' => 'c', 'user_id' => 3, 'result' => 2, 'ip' => '10.0.0.3', 'time' => '2026-07-25 13:00:02'] +]; + +audit_poll_user_log(); +audit_test_assert_same(3, count($audit_auth_recorded_events), 'All three timestamp pages must be processed in one cycle.'); +$first_details = json_decode($audit_auth_recorded_events[0]['params'][24], true); +audit_test_assert_same('a', $first_details['username'], 'Rows must be ordered by time then username.'); + +// --------------------------------------------------------------------------- +// 5. Concurrent/overlapping poller ownership: no duplicates +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'concurrent', 'user_id' => 42, 'result' => 1, 'ip' => '10.0.0.99', 'time' => '2026-07-25 14:00:00'] +]; + +// Simulate a concurrent poller that already recorded the state row. +$hash = audit_user_log_source_hash('concurrent', 42, '2026-07-25 14:00:00'); +$audit_auth_state_rows[$hash] = [ + 'source_hash' => $hash, + 'source_key' => 'concurrent|42|2026-07-25 14:00:00', + 'source_time' => '2026-07-25 14:00:00', + 'audit_id' => 999, + 'processed_time' => '2026-07-25 14:00:01' +]; + +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A row already claimed by a concurrent poller must not be double-recorded.'); + +// Simulate both pollers selecting the row before either state marker is +// visible. The losing transaction must roll back its audit event when its +// INSERT IGNORE reports that another poller won the unique source hash. +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'racing', 'user_id' => 43, 'result' => 1, 'ip' => '10.0.0.100', 'time' => '2026-07-25 14:01:00'] +]; +$audit_auth_state_conflict = true; + +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A concurrent state-insert loser must roll back its duplicate audit event.'); +audit_test_assert_same(0, count($audit_auth_state_rows), 'A concurrent state-insert loser must not create a state marker.'); + +// --------------------------------------------------------------------------- +// 6. Failed audit inserts remain discoverable behind later successful rows +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'failinsert', 'user_id' => 50, 'result' => 1, 'ip' => '10.0.0.50', 'time' => '2026-07-25 15:00:00'], + ['username' => 'later', 'user_id' => 51, 'result' => 1, 'ip' => '10.0.0.51', 'time' => '2026-07-25 15:00:01'] +]; +$audit_auth_fail_usernames = ['failinsert']; + +audit_poll_user_log(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'A later row must still be recorded when an earlier audit insert fails.'); +audit_test_assert_same(1, count($audit_auth_state_rows), 'Only the successful later row may receive a state marker.'); + +// Retry after the insert recovers: the earlier row must remain discoverable +// even though a later source time has already been processed. +$audit_auth_fail_usernames = []; +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'A failed row behind a later success must be retried on the next cycle.'); +audit_test_assert_same(2, count($audit_auth_state_rows), 'The retried row must produce its durable state marker.'); + +// --------------------------------------------------------------------------- +// 7. Retention policy excludes arbitrary historical rows +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_config['audit_retention'] = '30'; +$audit_auth_user_log_rows = [ + ['username' => 'old', 'user_id' => 1, 'result' => 1, 'ip' => '10.0.0.1', 'time' => '2026-06-01 00:00:00'], + ['username' => 'recent', 'user_id' => 2, 'result' => 1, 'ip' => '10.0.0.2', 'time' => '2026-07-24 00:00:00'] +]; + +audit_poll_user_log(); +$recorded_usernames = []; + +foreach ($audit_auth_recorded_events as $event) { + $details = json_decode($event['params'][24], true); + + if (is_array($details) && isset($details['username'])) { + $recorded_usernames[] = $details['username']; + } +} + +audit_test_assert_true(!in_array('old', $recorded_usernames, true), 'Initial ingestion must not replay rows older than the retention cutoff.'); +audit_test_reset_state(); +$audit_auth_config['audit_retention'] = '90'; + +// --------------------------------------------------------------------------- +// 8. Brute-force: exact threshold, throttle boundary, concurrency +// --------------------------------------------------------------------------- + +audit_test_reset_state(); + +// Below threshold: no emit. +$audit_auth_failed_count = 9; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Below threshold must not emit.'); + +// Exactly at threshold: emit. +$audit_auth_failed_count = 10; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Exactly at threshold must emit even when the throttle setting row did not previously exist.'); +audit_test_assert_true(read_config_option('audit_brute_force_last_alert') !== '', 'Brute-force detection must initialize its throttle setting row.'); +audit_test_assert_same('cacti.auth.brute_force_suspected', $audit_auth_recorded_events[0]['params'][12], 'Brute-force event type must be correct.'); +audit_test_assert_same('critical', $audit_auth_recorded_events[0]['params'][14], 'Brute-force must be critical.'); + +// Within window: throttled (atomic UPDATE claims nothing). +$audit_auth_failed_count = 12; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Within the window, the atomic claim must throttle the second alert.'); + +// Concurrent check: second poller's UPDATE affects 0 rows. +$audit_auth_failed_count = 12; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A concurrent poller must not emit a duplicate alert.'); + +// Failed audit insert releases the slot. +audit_test_reset_state(); +$audit_auth_settings_rows['audit_brute_force_last_alert'] = ''; +$audit_auth_failed_count = 10; +$audit_auth_insert_fails = true; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same('', $audit_auth_settings_rows['audit_brute_force_last_alert'] ?? '', 'A failed audit insert must release the alert slot for retry.'); +$audit_auth_insert_fails = false; + +// Disabled must not emit. +$audit_auth_config['audit_brute_force_enabled'] = 'off'; +$audit_auth_failed_count = 50; +$audit_auth_settings_rows['audit_brute_force_last_alert'] = ''; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Disabled brute-force detection must not emit.'); +$audit_auth_config['audit_brute_force_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 9. custom_denied: returns mode, records event, redacts referer +// --------------------------------------------------------------------------- + +$_SESSION['sess_user_id'] = 5; +$_SERVER['SCRIPT_NAME'] = '/cacti/host.php'; +$_SERVER['HTTP_REFERER'] = 'https://cacti.example.com/index.php?token=secret&reset_hash=abc123'; +audit_test_reset_state(); + +$returned = audit_custom_denied('OPER_MODE_NATIVE'); +audit_test_assert_same('OPER_MODE_NATIVE', $returned, 'audit_custom_denied() must return the input mode unchanged.'); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'audit_custom_denied() must record one event.'); +audit_test_assert_same('cacti.auth.authorization.denied', $audit_auth_recorded_events[0]['params'][12], 'Denied event type must be correct.'); +$details_json = $audit_auth_recorded_events[0]['params'][24]; +$details = json_decode($details_json, true); +audit_test_assert_same('https://cacti.example.com/index.php', $details['referer_origin'], 'Referer query string must be stripped.'); +audit_test_assert_true(strpos($details_json, 'secret') === false, 'The referer token must not appear in details.'); +audit_test_assert_true(strpos($details_json, 'abc123') === false, 'The reset hash must not appear in details.'); + +// Disabled must not record but still return the mode. +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +$audit_auth_recorded_events = []; +audit_test_assert_same('OPER_MODE_NATIVE', audit_custom_denied('OPER_MODE_NATIVE'), 'audit_custom_denied() must always return the input mode.'); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'audit_custom_denied() must skip recording when disabled.'); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 10. Logout: master switch gates pre-destroy; post-destroy uses stash +// --------------------------------------------------------------------------- + +$_SESSION['sess_user_id'] = 5; +$_REQUEST['action'] = 'user'; +audit_test_reset_state(); + +audit_logout_pre_session_destroy(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Pre-destroy must record the logout event.'); +audit_test_assert_same('authentication.logout', $audit_auth_recorded_events[0]['params'][12], 'Pre-destroy event type must be correct.'); + +$audit_auth_recorded_events = []; +audit_logout_post_session_destroy(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Post-destroy must record one completed event.'); +audit_test_assert_same('authentication.logout.completed', $audit_auth_recorded_events[0]['params'][12], 'Post-destroy event type must be correct.'); +audit_test_assert_same(5, $audit_auth_recorded_events[0]['params'][1], 'Post-destroy must carry the stashed user_id.'); + +// Empty stash: no record. +$audit_auth_recorded_events = []; +audit_logout_post_session_destroy(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Post-destroy must not record when the stash is empty.'); + +// Master switch off: pre-destroy must not record. +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +$audit_auth_recorded_events = []; +audit_logout_pre_session_destroy(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Pre-destroy must not record when auth auditing is disabled.'); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 11. Unauthorized auth-settings save uses the generic event type +// --------------------------------------------------------------------------- + +// audit_enforce_syslog_settings_request() reads POST via filter_input_array +// (a PHP built-in reading the real SAPI POST body) and calls exit on 403, +// so it cannot be exercised behaviorally in a CLI test. Verify via a static +// source assertion that the auth-only unauthorized path emits the generic +// audit.configuration.denied event, distinct from the syslog-specific one. +$functions_source = file_get_contents(dirname(__DIR__) . '/audit_functions.php'); +audit_test_assert_true( + strpos($functions_source, "'audit.configuration.denied'") !== false, + 'The generic audit.configuration.denied event must be present for unauthorized auth-settings saves.' +); +audit_test_assert_true( + strpos($functions_source, "'audit.syslog.configuration.denied'") !== false, + 'The syslog-specific denied event must remain for unauthorized syslog-settings saves.' +); +// Confirm the auth-only branch exists (else branch after the syslog check). +audit_test_assert_true( + strpos($functions_source, 'audit.configuration.denied') !== false + && strpos($functions_source, 'audit_admin_required') !== false, + 'The unauthorized auth-settings save must record an audit_admin_required denied event.' +); + +print "Auth audit tests passed.\n"; diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 80ba0c0..40b348b 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -41,6 +41,16 @@ 'audit_retry_external_logs()', 'audit_process_syslog_queue()', 'logout_pre_session_destroy', + 'logout_post_session_destroy', + 'custom_denied', + 'audit_poll_user_log()', + 'audit_detect_brute_force()', + 'audit_auth_log_enabled', + 'audit_brute_force_enabled', + 'audit_user_log_batch_size', + 'audit_persist_auth_defaults', + 'CREATE TABLE IF NOT EXISTS `audit_user_log_state`', + 'DROP TABLE IF EXISTS audit_user_log_state', 'event_uuid char(36)', 'operation_outcome', 'external_attempts', @@ -63,6 +73,37 @@ "register_shutdown_function('audit_finalize_request', \$audit_id, \$started_at, \$verifier)" ]; +$required_auth_fragments = [ + 'function audit_poll_user_log', + 'function audit_detect_brute_force', + 'function audit_custom_denied', + 'function audit_logout_post_session_destroy', + 'function audit_user_log_event_descriptor', + "'cacti.auth.login.failed'", + "'cacti.auth.login.credentials_accepted'", + "'cacti.auth.login.token'", + "'cacti.auth.password.changed'", + "'cacti.auth.password_change_or_2fa_failed'", + "'cacti.auth.login.unknown'", + "'cacti.auth.brute_force_suspected'", + "'cacti.auth.authorization.denied'", + "'authentication.logout.completed'", + "'audit.configuration.denied'", + "'START TRANSACTION'", + "'ROLLBACK'", + "'COMMIT'", + "'defer_delivery'", + 'FROM audit_user_log_state AS auls', + 'INSERT IGNORE INTO settings (name, value)' +]; + +foreach ($required_auth_fragments as $fragment) { + if (strpos($functions, $fragment) === false) { + fwrite(STDERR, 'Missing authentication audit requirement: ' . $fragment . PHP_EOL); + exit(1); + } +} + foreach ($required_verifier_fragments as $fragment) { if (strpos($functions, $fragment) === false) { fwrite(STDERR, 'Missing operation verification requirement: ' . $fragment . PHP_EOL); From 8deac3e46f6d770abe74aaf923892b4b600ce510 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:13:21 -0400 Subject: [PATCH 2/9] fix syntax --- audit_functions.php | 8 ++++---- tests/auth_audit_test.php | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/audit_functions.php b/audit_functions.php index a37c1c3..fb3f884 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -962,10 +962,10 @@ function audit_poll_user_log(): void { CONCAT(ul.username, "|", ul.user_id, "|", ul.time), 256 ) - ) - ORDER BY ul.time ASC, ul.username ASC, ul.user_id ASC - LIMIT ?', - [$cutoff, $batch_size] + ) + ORDER BY ul.time ASC, ul.username ASC, ul.user_id ASC + LIMIT ' . $batch_size, + [$cutoff] ); if (!is_array($rows) || cacti_sizeof($rows) === 0) { diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php index f043aa5..3fb34d6 100644 --- a/tests/auth_audit_test.php +++ b/tests/auth_audit_test.php @@ -233,8 +233,9 @@ function db_fetch_assoc_prepared(string $sql, array $params = []): array { return $a['user_id'] <=> $b['user_id']; }); - $limit_idx = count($params) - 1; - $limit = (int) ($params[$limit_idx] ?? 1000); + $limit = preg_match('/LIMIT\\s+(\\d+)/i', $sql, $matches) + ? (int) $matches[1] + : 1000; return array_slice($filtered, 0, $limit); } From 543b67981f0ff21f5f240a05380bff2b59352abf Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:22:08 -0400 Subject: [PATCH 3/9] Fix cleanup during uninstall --- .github/workflows/plugin-ci-workflow.yml | 20 +++++++++++++++ CHANGELOG.md | 2 ++ audit_functions.php | 18 +++++++++++--- audit_syslog.php | 4 ++- setup.php | 4 +++ tests/auth_audit_test.php | 31 +++++++++++++++++++++--- tests/controller_security_test.php | 2 ++ tests/syslog_queue_test.php | 2 +- 8 files changed, 75 insertions(+), 8 deletions(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 451d674..b615982 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -343,3 +343,23 @@ jobs: echo "Unexpected CLI request status: $CLI_STATUS" exit 1 fi + + - name: Verify plugin uninstall cleanup + run: | + cd ${{ github.workspace }}/cacti + sudo php cli/plugin_manage.php --plugin=audit --disable --uninstall + + AUDIT_SETTING_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) FROM settings WHERE LEFT(name, 6) = 'audit_'; + ") + AUDIT_TABLE_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'cacti' + AND table_name IN ('audit_log', 'audit_syslog_delivery', 'audit_user_log_state'); + ") + + if [ "$AUDIT_SETTING_COUNT" -ne 0 ] || [ "$AUDIT_TABLE_COUNT" -ne 0 ]; then + echo "Audit plugin uninstall left settings or tables behind" + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d7823..4f43e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ * security: Restrict authentication auditing and brute-force detection settings to Audit Log Admin users and enforce authorization on save * security: Gate the original logout event behind the authentication auditing master switch * security: Persist authentication defaults on install and upgrade without overwriting existing administrator choices +* issue: Remove all plugin-owned audit settings during uninstall +* issue: Make late request-finalization callbacks safe after plugin tables are removed * issue: Test integration CI against both Cacti 1.2.x and develop branches * feature: Add standards-based remote Syslog delivery over UDP, TCP, and verified TLS diff --git a/audit_functions.php b/audit_functions.php index fb3f884..6af424d 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -6,6 +6,10 @@ function audit_user_is_admin(): bool { return api_plugin_user_realm_auth('audit_manage.php'); } +function audit_log_table_available(): bool { + return function_exists('db_table_exists') && db_table_exists('audit_log'); +} + /** * @param array $selected_items */ @@ -428,6 +432,10 @@ function audit_append_external_log(string $path, string $message): array { } function audit_set_external_status(int $id, string $status, string $error = ''): void { + if (!audit_log_table_available()) { + return; + } + db_execute_prepared('UPDATE audit_log SET external_status = ?, external_error = ?, @@ -439,7 +447,7 @@ function audit_set_external_status(int $id, string $status, string $error = ''): } function audit_deliver_external_event(int $id): void { - if (read_config_option('audit_log_external') != 'on') { + if (!audit_log_table_available() || read_config_option('audit_log_external') != 'on') { return; } @@ -464,7 +472,7 @@ function audit_deliver_external_event(int $id): void { } function audit_retry_external_logs(): void { - if (read_config_option('audit_log_external') != 'on') { + if (!audit_log_table_available() || read_config_option('audit_log_external') != 'on') { return; } @@ -624,6 +632,10 @@ function audit_verify_operation(mixed $verifier): array { * @param array|null $verifier */ function audit_finalize_request(int $id, ?float $started_at = null, ?array $verifier = null): void { + if (!audit_log_table_available()) { + return; + } + $status_code = http_response_code(); $status_code = is_int($status_code) ? $status_code : 200; $request_status = audit_request_status(error_get_last(), $status_code); @@ -665,7 +677,7 @@ function audit_finalize_request(int $id, ?float $started_at = null, ?array $veri * @param array $options */ function audit_record_event(string $event_type, array $options = []): int { - if (read_config_option('audit_enabled') != 'on') { + if (!audit_log_table_available() || read_config_option('audit_enabled') != 'on') { return 0; } diff --git a/audit_syslog.php b/audit_syslog.php index 10ff4f0..46245bf 100644 --- a/audit_syslog.php +++ b/audit_syslog.php @@ -709,7 +709,9 @@ function audit_syslog_send_event(array $event, array $config, mixed &$socket = n } function audit_enqueue_syslog_event(int $audit_id): void { - if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) { + if (!audit_syslog_enabled() || + !db_table_exists('audit_log') || + !db_table_exists('audit_syslog_delivery')) { return; } diff --git a/setup.php b/setup.php index 3f04487..09f81d0 100644 --- a/setup.php +++ b/setup.php @@ -139,6 +139,10 @@ function plugin_audit_uninstall(): bool { db_execute('DROP TABLE IF EXISTS audit_user_log_state'); db_execute('DROP TABLE IF EXISTS audit_syslog_delivery'); db_execute('DROP TABLE IF EXISTS audit_log'); + db_execute_prepared( + 'DELETE FROM settings WHERE LEFT(name, 6) = ?', + ['audit_'] + ); return true; } diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php index 3fb34d6..fecf15b 100644 --- a/tests/auth_audit_test.php +++ b/tests/auth_audit_test.php @@ -32,6 +32,8 @@ $audit_auth_state_conflict = false; $audit_auth_affected_rows = 0; $audit_auth_transaction = null; +$audit_auth_log_exists = true; +$audit_auth_executed_sql = []; function read_config_option(string $name, bool $force = false): string { global $audit_auth_config, $audit_auth_settings_rows; @@ -51,7 +53,9 @@ function set_config_option(string $name, string $value): void { } function db_execute_prepared(string $sql, array $params = []): bool { - global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction; + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction, $audit_auth_executed_sql; + + $audit_auth_executed_sql[] = $sql; if (strpos($sql, 'START TRANSACTION') !== false) { $audit_auth_transaction = [ @@ -263,6 +267,12 @@ function db_affected_rows(): int { } function db_table_exists(string $table): bool { + global $audit_auth_log_exists; + + if ($table === 'audit_log') { + return $audit_auth_log_exists; + } + return in_array($table, ['user_log', 'audit_user_log_state'], true); } @@ -310,7 +320,7 @@ function audit_test_assert_true(bool $condition, string $message): void { } function audit_test_reset_state(): void { - global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_set_options, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction; + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_set_options, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction, $audit_auth_log_exists, $audit_auth_executed_sql; $audit_auth_recorded_events = []; $audit_auth_state_rows = []; $audit_auth_set_options = []; @@ -320,6 +330,8 @@ function audit_test_reset_state(): void { $audit_auth_state_conflict = false; $audit_auth_affected_rows = 0; $audit_auth_transaction = null; + $audit_auth_log_exists = true; + $audit_auth_executed_sql = []; } // --------------------------------------------------------------------------- @@ -631,7 +643,20 @@ function audit_test_reset_state(): void { $audit_auth_config['audit_auth_log_enabled'] = 'on'; // --------------------------------------------------------------------------- -// 11. Unauthorized auth-settings save uses the generic event type +// 11. Uninstall lifecycle: shutdown callbacks tolerate removed tables +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_log_exists = false; + +audit_test_assert_same(0, audit_record_event('audit.test.after_uninstall'), 'Recording must no-op after audit_log is removed.'); +audit_finalize_request(123); +audit_deliver_external_event(123); +audit_retry_external_logs(); +audit_test_assert_same([], $audit_auth_executed_sql, 'Late callbacks must not query audit_log after plugin uninstall.'); + +// --------------------------------------------------------------------------- +// 12. Unauthorized auth-settings save uses the generic event type // --------------------------------------------------------------------------- // audit_enforce_syslog_settings_request() reads POST via filter_input_array diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 40b348b..b9f19c7 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -51,6 +51,8 @@ 'audit_persist_auth_defaults', 'CREATE TABLE IF NOT EXISTS `audit_user_log_state`', 'DROP TABLE IF EXISTS audit_user_log_state', + "'DELETE FROM settings WHERE LEFT(name, 6) = ?'", + "['audit_']", 'event_uuid char(36)', 'operation_outcome', 'external_attempts', diff --git a/tests/syslog_queue_test.php b/tests/syslog_queue_test.php index b88913c..eb3b4a9 100644 --- a/tests/syslog_queue_test.php +++ b/tests/syslog_queue_test.php @@ -31,7 +31,7 @@ function read_config_option($name) { } function db_table_exists($table) { - return $table === 'audit_syslog_delivery'; + return in_array($table, ['audit_log', 'audit_syslog_delivery'], true); } function db_fetch_row_prepared($sql, $params = []) { From 85bdac8826a22b99739d9f655ab5207a22c1dd6d Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:26:32 -0400 Subject: [PATCH 4/9] . --- CHANGELOG.md | 3 --- audit.php | 32 +++++++++++++++++++----------- tests/controller_security_test.php | 9 ++++++++- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f43e4f..366a7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,6 @@ * security: Restrict authentication auditing and brute-force detection settings to Audit Log Admin users and enforce authorization on save * security: Gate the original logout event behind the authentication auditing master switch * security: Persist authentication defaults on install and upgrade without overwriting existing administrator choices -* issue: Remove all plugin-owned audit settings during uninstall -* issue: Make late request-finalization callbacks safe after plugin tables are removed -* issue: Test integration CI against both Cacti 1.2.x and develop branches * feature: Add standards-based remote Syslog delivery over UDP, TCP, and verified TLS * feature: Add RFC 5424 headers with RFC 5424, CEF, or compact JSON message formats diff --git a/audit.php b/audit.php index dcbf213..5347f98 100644 --- a/audit.php +++ b/audit.php @@ -177,7 +177,7 @@ function audit_render_event_details(array $data): string { $output .= '
' . __('External File Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; } - if (db_table_exists('audit_syslog_delivery')) { + if (audit_syslog_enabled() && db_table_exists('audit_syslog_delivery')) { $syslog = db_fetch_row_prepared('SELECT state, attempts, last_attempt, sent_time, last_error FROM audit_syslog_delivery WHERE audit_id = ? @@ -185,16 +185,21 @@ function audit_render_event_details(array $data): string { LIMIT 1', [$data['id']]); - if (is_array($syslog)) { - $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($syslog['state']) . ''; - $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . (int) $syslog['attempts'] . ''; + if (is_array($syslog) && cacti_sizeof($syslog) > 0) { + $state = (string) ($syslog['state'] ?? 'unknown'); + $attempts = (int) ($syslog['attempts'] ?? 0); + $sent_time = (string) ($syslog['sent_time'] ?? ''); + $last_error = (string) ($syslog['last_error'] ?? ''); - if ($syslog['sent_time'] != '') { - $output .= '
' . __('Syslog Socket Write:', 'audit') . ' ' . html_escape($syslog['sent_time']) . ''; + $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($state) . ''; + $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . $attempts . ''; + + if ($sent_time != '') { + $output .= '
' . __('Syslog Socket Write:', 'audit') . ' ' . html_escape($sent_time) . ''; } - if ($syslog['last_error'] != '') { - $output .= '
' . __('Syslog Error:', 'audit') . ' ' . html_escape($syslog['last_error']) . ''; + if ($last_error != '') { + $output .= '
' . __('Syslog Error:', 'audit') . ' ' . html_escape($last_error) . ''; } } } @@ -720,10 +725,13 @@ function audit_log(): void { } function audit_render_syslog_health(): void { + if (!audit_syslog_enabled()) { + return; + } + $config = audit_syslog_config(); $health = audit_syslog_health(); - $enabled = audit_syslog_enabled(); - $unhealthy = $enabled && ( + $unhealthy = ( !$config['valid'] || $health['dead_letter'] >= $config['dead_letter_warning'] || $health['oldest_pending_seconds'] >= $config['pending_age_warning'] @@ -733,7 +741,7 @@ function audit_render_syslog_health(): void { print ""; print '' . __('Status', 'audit') . ''; - print '' . html_escape(!$enabled ? __('Disabled', 'audit') : ($unhealthy ? __('Unhealthy', 'audit') : __('Healthy', 'audit'))) . ''; + print '' . html_escape($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'] . ''; @@ -753,7 +761,7 @@ function audit_render_syslog_health(): void { } print ''; - if ($enabled && !$config['valid']) { + if (!$config['valid']) { print "" . __esc('Configuration Error:', 'audit') . ' ' . html_escape(implode(', ', $config['errors'])) . ''; } elseif ($health['last_error'] !== null) { diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index b9f19c7..c6de27d 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -16,7 +16,14 @@ "case 'syslog_test':", "case 'syslog_retry':", 'audit_syslog_test_delivery()', - 'audit_syslog_retry_dead_letters($delivery_ids)' + 'audit_syslog_retry_dead_letters($delivery_ids)', + "if (!audit_syslog_enabled()) {\n\t\treturn;", + "if (audit_syslog_enabled() && db_table_exists('audit_syslog_delivery'))", + 'cacti_sizeof($syslog) > 0', + '$syslog[\'state\'] ?? \'unknown\'', + '$syslog[\'attempts\'] ?? 0', + '$syslog[\'sent_time\'] ?? \'\'', + '$syslog[\'last_error\'] ?? \'\'' ]; foreach ($required_controller_guards as $guard) { From 120d135c2635a8766dd21ffd8a6038b8abd74692 Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Sat, 25 Jul 2026 22:32:54 -0400 Subject: [PATCH 5/9] Persist syslog batch settings And fix bruteforce detection settings --- audit_functions.php | 6 +++++- setup.php | 23 ++++++++++++----------- tests/auth_audit_test.php | 10 ++++++++++ tests/controller_security_test.php | 2 ++ 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/audit_functions.php b/audit_functions.php index 6af424d..57591a6 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -1243,7 +1243,11 @@ function audit_enforce_syslog_settings_request(): void { if (strpos($name, 'audit_syslog_') === 0) { $has_syslog_fields = true; - } elseif (strpos($name, 'audit_auth_') === 0 || strpos($name, 'audit_brute_force_') === 0) { + } elseif ( + strpos($name, 'audit_auth_') === 0 || + strpos($name, 'audit_brute_force_') === 0 || + $name === 'audit_user_log_batch_size' + ) { $has_auth_fields = true; } } diff --git a/setup.php b/setup.php index 09f81d0..7722254 100644 --- a/setup.php +++ b/setup.php @@ -58,7 +58,8 @@ function audit_persist_auth_defaults(): void { 'audit_brute_force_enabled' => 'on', 'audit_brute_force_window_minutes' => '5', 'audit_brute_force_threshold' => '10', - 'audit_brute_force_last_alert' => '' + 'audit_brute_force_last_alert' => '', + 'audit_user_log_batch_size' => '1000' ]; foreach ($defaults as $name => $value) { @@ -708,15 +709,15 @@ function audit_config_settings(): void { 'max_length' => '4', 'size' => '8' ], - 'audit_user_log_batch_size' => [ - 'friendly_name' => __('User Log Ingestion Batch Size', 'audit'), - 'description' => __('Maximum user_log rows ingested per poller cycle, from 1 through 5000. Larger batches process backlogs faster but increase poller runtime.', 'audit'), - 'method' => 'textbox', - 'default' => '1000', - 'max_length' => '4', - 'size' => '8' - ], - ]; + 'audit_user_log_batch_size' => [ + 'friendly_name' => __('User Log Ingestion Batch Size', 'audit'), + 'description' => __('Maximum user_log rows ingested per poller cycle, from 1 through 5000. Larger batches process backlogs faster but increase poller runtime.', 'audit'), + 'method' => 'textbox', + 'default' => '1000', + 'max_length' => '4', + 'size' => '8' + ], + ]; $facility_options = []; @@ -891,7 +892,7 @@ function audit_config_settings(): void { ] ]; - $temp = array_merge($temp, $syslog); + $temp = array_merge($temp, $auth_settings, $syslog); } $tabs['audit'] = __('Audit', 'audit'); diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php index fecf15b..b069d5b 100644 --- a/tests/auth_audit_test.php +++ b/tests/auth_audit_test.php @@ -665,6 +665,12 @@ function audit_test_reset_state(): void { // source assertion that the auth-only unauthorized path emits the generic // audit.configuration.denied event, distinct from the syslog-specific one. $functions_source = file_get_contents(dirname(__DIR__) . '/audit_functions.php'); + +if (!is_string($functions_source)) { + fwrite(STDERR, 'Unable to read audit_functions.php for settings authorization checks.' . PHP_EOL); + exit(1); +} + audit_test_assert_true( strpos($functions_source, "'audit.configuration.denied'") !== false, 'The generic audit.configuration.denied event must be present for unauthorized auth-settings saves.' @@ -679,5 +685,9 @@ function audit_test_reset_state(): void { && strpos($functions_source, 'audit_admin_required') !== false, 'The unauthorized auth-settings save must record an audit_admin_required denied event.' ); +audit_test_assert_true( + preg_match('/\\$name\\s*===\\s*[\'"]audit_user_log_batch_size[\'"]/', $functions_source) === 1, + 'The user_log ingestion batch-size setting must receive the same Audit Log Admin protection as authentication settings.' +); print "Auth audit tests passed.\n"; diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index c6de27d..6188e54 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -55,6 +55,8 @@ 'audit_auth_log_enabled', 'audit_brute_force_enabled', 'audit_user_log_batch_size', + 'array_merge($temp, $auth_settings, $syslog)', + "'audit_user_log_batch_size' => '1000'", 'audit_persist_auth_defaults', 'CREATE TABLE IF NOT EXISTS `audit_user_log_state`', 'DROP TABLE IF EXISTS audit_user_log_state', From 466f20cf297d21d5005902e46e21df24fd73866e Mon Sep 17 00:00:00 2001 From: Sean Mancini Date: Tue, 28 Jul 2026 20:27:37 -0400 Subject: [PATCH 6/9] Address PR 64 review feedback --- audit.php | 2 +- tests/controller_security_test.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/audit.php b/audit.php index 5347f98..f3d83b7 100644 --- a/audit.php +++ b/audit.php @@ -128,7 +128,7 @@ WHERE id = ?', [get_filter_request_var('id')]); - if (!is_array($data)) { + if (!is_array($data) || $data === []) { http_response_code(404); print html_escape(__('Audit event not found.', 'audit')); diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 6188e54..f8ea35c 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -17,6 +17,7 @@ "case 'syslog_retry':", 'audit_syslog_test_delivery()', 'audit_syslog_retry_dead_letters($delivery_ids)', + 'if (!is_array($data) || $data === [])', "if (!audit_syslog_enabled()) {\n\t\treturn;", "if (audit_syslog_enabled() && db_table_exists('audit_syslog_delivery'))", 'cacti_sizeof($syslog) > 0', From f14b9bb82b31af8c23da5e7efb0e8fd9eabb57e8 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 28 Aug 2026 14:11:59 -0700 Subject: [PATCH 7/9] fix(auth): harden audit event ingestion --- .github/workflows/code-quality.yml | 3 + .github/workflows/plugin-ci-workflow.yml | 106 +++- CHANGELOG.md | 15 +- INFO | 2 +- README.md | 68 +- audit.php | 17 +- audit_auth_indexes.php | 17 + audit_functions.php | 583 ++++++++++++----- audit_syslog.php | 4 +- phpstan/stubs/cacti.stubs.php | 4 + setup.php | 282 +++++++-- tests/auth_audit_coverage_test.php | 73 +++ tests/auth_audit_test.php | 755 ++++++++++++++++++----- tests/auth_sql_integration_test.php | 43 ++ tests/controller_security_test.php | 86 ++- tests/security_functions_test.php | 8 + tests/setup_defaults_test.php | 71 +++ tests/setup_index_test.php | 182 ++++++ 18 files changed, 1876 insertions(+), 443 deletions(-) create mode 100644 audit_auth_indexes.php create mode 100644 tests/auth_audit_coverage_test.php create mode 100644 tests/auth_sql_integration_test.php create mode 100644 tests/setup_defaults_test.php create mode 100644 tests/setup_index_test.php diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 4f28ea1..da96174 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -97,5 +97,8 @@ jobs: php tests/controller_security_test.php php tests/syslog_queue_test.php php tests/auth_audit_test.php + phpdbg -qrr tests/auth_audit_coverage_test.php + php tests/setup_defaults_test.php + php tests/setup_index_test.php timeout 60 php tests/syslog_functions_test.php working-directory: cacti/plugins/audit diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index b615982..1ed7180 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -109,15 +109,15 @@ jobs: - name: Initialize Cacti Database env: - MYSQL_AUTH_USR: '--defaults-file=~/.my.cnf' + MYSQL_AUTH_FILE: /home/runner/.my.cnf run: | - mysql $MYSQL_AUTH_USR -e 'CREATE DATABASE IF NOT EXISTS cacti;' - mysql $MYSQL_AUTH_USR -e "CREATE USER IF NOT EXISTS 'cactiuser'@'localhost' IDENTIFIED BY 'cactiuser';" - mysql $MYSQL_AUTH_USR -e "GRANT ALL PRIVILEGES ON cacti.* TO 'cactiuser'@'localhost';" - mysql $MYSQL_AUTH_USR -e "GRANT SELECT ON mysql.time_zone_name TO 'cactiuser'@'localhost';" - mysql $MYSQL_AUTH_USR -e "FLUSH PRIVILEGES;" - mysql $MYSQL_AUTH_USR cacti < ${{ github.workspace }}/cacti/cacti.sql - mysql $MYSQL_AUTH_USR -e "INSERT INTO settings (name, value) VALUES ('path_php_binary', '/usr/bin/php')" cacti + mysql --defaults-file="$MYSQL_AUTH_FILE" -e 'CREATE DATABASE IF NOT EXISTS cacti;' + mysql --defaults-file="$MYSQL_AUTH_FILE" -e "CREATE USER IF NOT EXISTS 'cactiuser'@'localhost' IDENTIFIED BY 'cactiuser';" + mysql --defaults-file="$MYSQL_AUTH_FILE" -e "GRANT ALL PRIVILEGES ON cacti.* TO 'cactiuser'@'localhost';" + mysql --defaults-file="$MYSQL_AUTH_FILE" -e "GRANT SELECT ON mysql.time_zone_name TO 'cactiuser'@'localhost';" + mysql --defaults-file="$MYSQL_AUTH_FILE" -e "FLUSH PRIVILEGES;" + mysql --defaults-file="$MYSQL_AUTH_FILE" cacti < ${{ github.workspace }}/cacti/cacti.sql + mysql --defaults-file="$MYSQL_AUTH_FILE" -e "INSERT INTO settings (name, value) VALUES ('path_php_binary', '/usr/bin/php')" cacti - name: Validate composer files run: | @@ -233,10 +233,7 @@ jobs: - name: Check PHP Syntax for Plugin run: | cd ${{ github.workspace }}/cacti/plugins/audit - if find . -name '*.php' -exec php -l {} 2>&1 \; | grep -iv 'no syntax errors detected'; then - echo "Syntax errors found!" - exit 1 - fi + find . -path './vendor' -prune -o -type f -name '*.php' -print0 | xargs -0 -r -n1 php -l - name: Run Audit Security Helper Tests run: | @@ -246,6 +243,9 @@ jobs: php tests/syslog_functions_test.php php tests/syslog_queue_test.php php tests/auth_audit_test.php + phpdbg -qrr tests/auth_audit_coverage_test.php + php tests/setup_defaults_test.php + php tests/setup_index_test.php - name: Run Cacti Poller run: | @@ -279,12 +279,35 @@ jobs: - name: Exercise authentication ingestion run: | mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -e " + UPDATE settings + SET value = CASE name + WHEN 'audit_user_log_watermark_epoch' THEN UNIX_TIMESTAMP() - 60 + ELSE 'on' + END + WHERE name IN ( + 'audit_auth_log_enabled', + 'audit_auth_log_last_state', + 'audit_brute_force_enabled', + 'audit_user_log_watermark_epoch' + ); INSERT INTO user_log (username, user_id, time, result, ip) VALUES ('audit_ci_login', 0, NOW(), 0, '192.0.2.10') ON DUPLICATE KEY UPDATE result = VALUES(result), ip = VALUES(ip); " cd ${{ github.workspace }}/cacti - sudo php poller.php --poller=1 --force --debug + sudo php plugins/audit/tests/auth_sql_integration_test.php + + AUTH_INDEX_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(DISTINCT index_name) + FROM information_schema.statistics + WHERE table_schema = 'cacti' + AND table_name = 'user_log' + AND index_name IN ('plugin_audit_time', 'plugin_audit_result_time'); + ") + if [ "$AUTH_INDEX_COUNT" -ne 2 ]; then + echo "Authentication audit indexes are missing after opt-in setup" + exit 1 + fi AUTH_EVENT_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " SELECT COUNT(*) @@ -295,14 +318,13 @@ jobs: AUTH_STATE_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " SELECT COUNT(*) FROM audit_user_log_state - WHERE source_hash = SHA2( - CONCAT('audit_ci_login', '|', 0, '|', ( - SELECT time FROM user_log + WHERE source_username = 'audit_ci_login' + AND source_user_id = 0 + AND source_epoch = ( + SELECT UNIX_TIMESTAMP(time) FROM user_log WHERE username = 'audit_ci_login' AND user_id = 0 ORDER BY time DESC LIMIT 1 - )), - 256 - ); + ); ") if [ "$AUTH_EVENT_COUNT" -ne 1 ] || [ "$AUTH_STATE_COUNT" -ne 1 ]; then @@ -328,6 +350,35 @@ jobs: exit 1 fi + mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -e " + INSERT INTO user_log (username, user_id, time, result, ip) VALUES + ('audit_ci_login_02', 0, NOW(), 0, '192.0.2.11'), + ('audit_ci_login_03', 0, NOW(), 0, '192.0.2.12'), + ('audit_ci_login_04', 0, NOW(), 0, '192.0.2.13'), + ('audit_ci_login_05', 0, NOW(), 0, '192.0.2.14'), + ('audit_ci_login_06', 0, NOW(), 0, '192.0.2.15'), + ('audit_ci_login_07', 0, NOW(), 0, '192.0.2.16'), + ('audit_ci_login_08', 0, NOW(), 0, '192.0.2.17'), + ('audit_ci_login_09', 0, NOW(), 0, '192.0.2.18'), + ('audit_ci_login_10', 0, NOW(), 0, '192.0.2.19'); + " + sudo php poller.php --poller=1 --force --debug + + ANOMALY_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(*) + FROM audit_log + WHERE event_type = 'cacti.auth.failed_login_volume_anomaly' + AND target_type = 'authentication_environment' + AND target_id = 'global' + AND JSON_UNQUOTE(JSON_EXTRACT(details, '$.scope')) = 'global' + AND JSON_EXTRACT(details, '$.distinct_usernames') >= 10 + AND JSON_EXTRACT(details, '$.distinct_ips') >= 10; + ") + if [ "$ANOMALY_COUNT" -ne 1 ]; then + echo "Global failed-login volume anomaly was not recorded with source cardinality" + exit 1 + fi + - name: check audit log entries run: | cd ${{ github.workspace }} @@ -353,13 +404,20 @@ jobs: SELECT COUNT(*) FROM settings WHERE LEFT(name, 6) = 'audit_'; ") AUDIT_TABLE_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " - SELECT COUNT(*) - FROM information_schema.tables + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'cacti' + AND table_name IN ('audit_log', 'audit_syslog_delivery', 'audit_user_log_state'); + ") + AUDIT_INDEX_COUNT=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se " + SELECT COUNT(DISTINCT index_name) + FROM information_schema.statistics WHERE table_schema = 'cacti' - AND table_name IN ('audit_log', 'audit_syslog_delivery', 'audit_user_log_state'); + AND table_name = 'user_log' + AND index_name IN ('plugin_audit_time', 'plugin_audit_result_time'); ") - if [ "$AUDIT_SETTING_COUNT" -ne 0 ] || [ "$AUDIT_TABLE_COUNT" -ne 0 ]; then - echo "Audit plugin uninstall left settings or tables behind" + if [ "$AUDIT_SETTING_COUNT" -ne 0 ] || [ "$AUDIT_TABLE_COUNT" -ne 0 ] || [ "$AUDIT_INDEX_COUNT" -ne 0 ]; then + echo "Audit plugin uninstall left settings, tables, or indexes behind" exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 366a7ed..61d5469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,23 @@ # ChangeLog ---- develop --- +--- 1.6 --- * feature: Capture login failure, token, credentials-accepted, and authorization-denied events by polling the Cacti user_log table across all authentication methods -* feature: Ingest user_log every poller cycle with bounded anti-join paging and transactionally durable database-backed deduplication via audit_user_log_state +* feature: Ingest user_log every poller cycle with bounded high-water paging and retry-safe claim-first deduplication via audit_user_log_state * feature: Apply the audit retention cutoff to every ingestion batch so historical rows are not replayed -* feature: Detect brute-force login patterns every poller cycle with atomically throttled critical alerts across concurrent pollers -* feature: Capture authorization-denied events through Cacti's custom_denied hook without taking over the denied-page rendering, with referer query strings redacted +* feature: Detect installation-wide failed-login volume anomalies every poller cycle with explicit global scope, source cardinality, and atomically throttled alerts +* performance: Add index-backed access paths for bounded user_log ingestion and failed-login aggregation +* feature: Capture authorization-denied events through Cacti's custom_denied hook without taking over the denied-page rendering, with referer paths and query strings redacted * feature: Confirm session teardown through the logout_post_session_destroy hook, correlated with the existing pre-destroy logout event * security: Record user_log result=1 as credentials_accepted with unknown outcome, not a confirmed login success * security: Record ambiguous user_log result=3/user_id=0 and unsupported result codes as unknown rather than misclassifying them * security: Restrict authentication auditing and brute-force detection settings to Audit Log Admin users and enforce authorization on save * security: Gate the original logout event behind the authentication auditing master switch -* security: Persist authentication defaults on install and upgrade without overwriting existing administrator choices +* security: Make authentication auditing opt-in, seed upgrades at the current epoch, and preserve existing administrator choices +* security: Bound failed-row retries, reserve ingestion capacity for new rows, and recover interrupted finalization through deterministic event UUIDs +* security: Make marker cleanup replay-safe and rate-proportional, with terminal-loss evidence retained in the Cacti log when audit table writes fail +* security: Restrict the audit master switch, retention, and external file controls to Audit Log Admin users +* performance: Create and remove plugin-owned user_log indexes only when authentication auditing is enabled or disabled * feature: Add standards-based remote Syslog delivery over UDP, TCP, and verified TLS * feature: Add RFC 5424 headers with RFC 5424, CEF, or compact JSON message formats diff --git a/INFO b/INFO index fe5f4b5..ca98b49 100644 --- a/INFO +++ b/INFO @@ -21,7 +21,7 @@ [info] name = audit -version = 1.5 +version = 1.6 longname = Audit Plugin for Cacti author = The Cacti Group email = diff --git a/README.md b/README.md index be2003f..64e6eb6 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ from the poller and through the `custom_denied` hook. The `user_log` table is the authoritative source across all Cacti authentication methods (local, LDAP, basic, and domains) and is stable across the 1.2.x and develop branches, so the plugin does not rely on the local-auth-only `login_process` hook. +Authentication ingestion and failed-login volume detection are opt-in. The +install/upgrade watermark starts at the current time, so enabling the feature +does not backfill historical login records. Cacti writes `user_log` `result = 1` before verifying that the account is enabled, authorized for any realm, or has completed 2FA. The plugin therefore @@ -140,27 +143,50 @@ the plugin records these as `cacti.auth.password_change_or_2fa_failed` with Ingestion runs every poller cycle with a bounded workload (default 1000 rows per cycle, configurable via `audit_user_log_batch_size`). Deduplication is durable and database-backed: each processed `user_log` primary-key tuple is -recorded in an `audit_user_log_state` table as a deterministic SHA-256 hash, -so repeated and concurrent pollers cannot double-record the same source row. -Each bounded query selects recent source rows without a durable marker, so -failed inserts and late commits remain discoverable. The audit row and state -marker are committed in one transaction before external delivery; a -concurrent loser rolls back its duplicate audit row. State markers survive -audit-log purges and are retired only after their source rows fall outside -the configured retention window. - -Every ingestion query applies the audit retention cutoff -(`NOW() - audit_retention` days, or 90 days if retention is indefinite), so -arbitrary historical `user_log` rows are not replayed and stale records are -not exported. - -Brute-force detection runs every poller cycle and emits a -`cacti.auth.brute_force_suspected` critical event when failed logins exceed a -configurable threshold within a rolling window. Alert emission is atomically -throttled to one event per window via a conditional `UPDATE` on the settings -table, so concurrent pollers cannot emit duplicate alerts. The throttle marker -is only persisted after a confirmed audit insert. Authentication auditing and -brute-force detection settings are restricted to Audit Log Admin users. +recorded once in typed `audit_user_log_state` columns, so repeated and +concurrent pollers cannot double-record the same source row. The source +timestamp is stored as a Unix epoch, so changing the MySQL session timezone +does not change event identity. A zero-valued marker claims a source +row before event creation; after the event is durable, the marker is finalized +with its audit ID. A deterministic event UUID reconnects an event inserted +before an interrupted finalization, preventing duplicates after a process +restart. Failed rows stop retrying after five attempts, while half of each +batch remains available for new rows. State markers survive audit-log purges +and expired markers are reclaimed every poller cycle at the configured +ingestion rate. Marker age is measured from claim time; completed markers +inside the five-minute replay floor remain in place, preventing duplicate +external delivery on quiet installations. Terminal retry markers are reported +to the Cacti log and become replayable after the fixed seven-day horizon. + +The state key mirrors Cacti's own `user_log` primary key +(`username`, `user_id`, `time`). Source rows that Cacti considers identical, +including same-second duplicates under the table's collation, cannot coexist in +`user_log` and therefore cannot be collapsed by plugin-side deduplication. + +Before enabling authentication auditing, an Audit Log Admin must explicitly +run `php plugins/audit/audit_auth_indexes.php`. The command verifies Cacti's +`user_log` identity contract and installs dedicated local indexes for +time-ordered ingestion and failed-login aggregation. Checkbox and poller paths +never run DDL against the core table. Uninstall removes only allowlisted, +plugin-owned indexes; remote collector schemas are not modified. + +Every ingestion query applies the later of the audit-retention cutoff and a +durable high-water mark minus a five-minute retry grace period. This bounds +steady-state work, keeps recent failed rows retryable, and prevents a retention +increase from replaying previously audited history. Pending retries are queried +separately and never lower that floor. Source epochs are rendered as UTC before +they are written to `audit_log.event_time`. + +Failed-login volume detection runs every poller cycle and emits a +`cacti.auth.failed_login_volume_anomaly` critical event when installation-wide +failed logins exceed a configurable threshold within a rolling window. The +event is explicitly global and reports distinct username and source-IP counts; +it does not attribute unrelated failures to one attacker. Alert emission is +atomically throttled to one event per window via a conditional `UPDATE` on the +settings table, so concurrent pollers cannot emit duplicate alerts. The +throttle marker is only persisted after a confirmed audit insert. +The audit master switch, retention, external-file, authentication-auditing, +and remote Syslog settings are restricted to Audit Log Admin users. Database-level changes and API activity remain outside the current Cacti 1.2.x scope. diff --git a/audit.php b/audit.php index f3d83b7..f023633 100644 --- a/audit.php +++ b/audit.php @@ -128,7 +128,7 @@ WHERE id = ?', [get_filter_request_var('id')]); - if (!is_array($data) || $data === []) { + if ($data === false || cacti_sizeof($data) === 0) { http_response_code(404); print html_escape(__('Audit event not found.', 'audit')); @@ -177,7 +177,7 @@ function audit_render_event_details(array $data): string { $output .= '
' . __('External File Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; } - if (audit_syslog_enabled() && db_table_exists('audit_syslog_delivery')) { + 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 = ? @@ -185,7 +185,7 @@ function audit_render_event_details(array $data): string { LIMIT 1', [$data['id']]); - if (is_array($syslog) && cacti_sizeof($syslog) > 0) { + if (cacti_sizeof($syslog) > 0) { $state = (string) ($syslog['state'] ?? 'unknown'); $attempts = (int) ($syslog['attempts'] ?? 0); $sent_time = (string) ($syslog['sent_time'] ?? ''); @@ -725,13 +725,10 @@ function audit_log(): void { } function audit_render_syslog_health(): void { - if (!audit_syslog_enabled()) { - return; - } - $config = audit_syslog_config(); $health = audit_syslog_health(); - $unhealthy = ( + $enabled = audit_syslog_enabled(); + $unhealthy = $enabled && ( !$config['valid'] || $health['dead_letter'] >= $config['dead_letter_warning'] || $health['oldest_pending_seconds'] >= $config['pending_age_warning'] @@ -741,7 +738,7 @@ function audit_render_syslog_health(): void { print ""; print '' . __('Status', 'audit') . ''; - print '' . html_escape($unhealthy ? __('Unhealthy', 'audit') : __('Healthy', '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'] . ''; @@ -761,7 +758,7 @@ function audit_render_syslog_health(): void { } print ''; - if (!$config['valid']) { + if ($enabled && !$config['valid']) { print "" . __esc('Configuration Error:', 'audit') . ' ' . html_escape(implode(', ', $config['errors'])) . ''; } elseif ($health['last_error'] !== null) { diff --git a/audit_auth_indexes.php b/audit_auth_indexes.php new file mode 100644 index 0000000..453b221 --- /dev/null +++ b/audit_auth_indexes.php @@ -0,0 +1,17 @@ +#!/usr/bin/env php + get_nfilter_request_var('action', 'user') ]); - if (read_config_option('audit_auth_log_enabled') != 'on') { - return; - } - $reason = get_nfilter_request_var('action', 'user'); - $type = $reason == 'timeout' ? 'authentication.session.expired' : 'authentication.logout'; + $type = $reason === 'timeout' ? 'authentication.session.expired' : 'authentication.logout'; audit_record_event($type, [ 'event_category' => 'authentication', - 'action' => $reason == 'timeout' ? 'timeout' : 'logout', + 'action' => $reason === 'timeout' ? 'timeout' : 'logout', 'details' => ['reason' => $reason] ]); } @@ -779,11 +779,11 @@ function audit_logout_stash(?array $set = null): array { } function audit_logout_post_session_destroy(): void { - if (read_config_option('audit_enabled') != 'on') { + if (read_config_option('audit_enabled') !== 'on') { return; } - if (read_config_option('audit_auth_log_enabled') != 'on') { + if (read_config_option('audit_auth_log_enabled') !== 'on') { return; } @@ -836,18 +836,15 @@ function audit_logout_post_session_destroy(): void { * @return array{event_type:string,severity:string,outcome:string,action:string,details:array} */ function audit_user_log_event_descriptor(int $result, int $user_id): array { - if ($result === 0) { - return [ + return match (true) { + $result === 0 => [ 'event_type' => 'cacti.auth.login.failed', 'severity' => 'warning', 'outcome' => 'failure', 'action' => 'login_failed', 'details' => [] - ]; - } - - if ($result === 1) { - return [ + ], + $result === 1 => [ 'event_type' => 'cacti.auth.login.credentials_accepted', 'severity' => 'info', 'outcome' => 'unknown', @@ -855,21 +852,15 @@ function audit_user_log_event_descriptor(int $result, int $user_id): array { 'details' => [ 'note' => __('Cacti records this outcome before verifying account enabled, realm authorization, or 2FA completion; a session may not have been established.', 'audit') ] - ]; - } - - if ($result === 2) { - return [ + ], + $result === 2 => [ 'event_type' => 'cacti.auth.login.token', 'severity' => 'info', 'outcome' => 'success', 'action' => 'login_token', 'details' => [] - ]; - } - - if ($result === 3 && $user_id > 0) { - return [ + ], + $result === 3 && $user_id > 0 => [ 'event_type' => 'cacti.auth.password.changed', 'severity' => 'info', 'outcome' => 'unknown', @@ -877,11 +868,8 @@ function audit_user_log_event_descriptor(int $result, int $user_id): array { 'details' => [ 'note' => __('No current Cacti path writes this signature; recorded defensively as a possible password change with an unconfirmed outcome.', 'audit') ] - ]; - } - - if ($result === 3) { - return [ + ], + $result === 3 => [ 'event_type' => 'cacti.auth.password_change_or_2fa_failed', 'severity' => 'info', 'outcome' => 'unknown', @@ -890,27 +878,116 @@ function audit_user_log_event_descriptor(int $result, int $user_id): array { 'ambiguous' => true, 'note' => __('Cacti user_log result=3 with user_id=0 may be a password change or a failed 2FA challenge; the table cannot disambiguate.', 'audit') ] - ]; + ], + default => [ + 'event_type' => 'cacti.auth.login.unknown', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'unknown_result', + 'details' => [ + 'unsupported_result_code' => $result + ] + ] + }; +} + +function audit_user_log_event_uuid(string $username, int $user_id, int $source_epoch): string { + $hex = hash('sha256', "cacti-audit-user-log\0{$username}\0{$user_id}\0{$source_epoch}"); + $variant = dechex((hexdec($hex[16]) & 0x3) | 0x8); + + return substr($hex, 0, 8) . '-' . + substr($hex, 8, 4) . '-5' . + substr($hex, 13, 3) . '-' . $variant . + substr($hex, 17, 3) . '-' . + substr($hex, 20, 12); +} + +function audit_log_ingestion_warning(string $message): void { + cacti_log('WARNING: ' . $message, false, 'POLLER'); +} + +function audit_report_ingestion_unavailable(string $reason): void { + audit_log_ingestion_warning('Authentication audit ingestion unavailable: ' . $reason); + + $now = time(); + $last = (int) read_config_option('audit_auth_ingestion_last_alert', true); + + if ($last > 0 && ($now - $last) < 3600) { + return; } - // Unsupported result code: record explicitly as unknown rather than - // falling through to a password-change or 2FA event. - return [ - 'event_type' => 'cacti.auth.login.unknown', - 'severity' => 'info', - 'outcome' => 'unknown', - 'action' => 'unknown_result', - 'details' => [ - 'unsupported_result_code' => $result - ] - ]; + set_config_option('audit_auth_ingestion_last_alert', (string) $now); + audit_record_event('audit.authentication.ingestion.unavailable', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'ingest', + 'target_type' => 'authentication_auditing', + 'operation_outcome' => 'failure', + 'outcome_reason' => $reason + ]); } -/** - * Compute a deterministic SHA-256 source identity for a user_log row. - */ -function audit_user_log_source_hash(string $username, int $user_id, string $time): string { - return hash('sha256', $username . '|' . $user_id . '|' . $time); +function audit_report_dropped_user_log_row(string $username, int $user_id, int $source_epoch, int $result): void { + $details = compact('username', 'user_id', 'source_epoch', 'result'); + + // The primary evidence channel must remain available when audit_log writes + // are the failure that exhausted retries. The structured event is best effort. + cacti_log( + 'ERROR: Authentication audit dropped user_log row after retry exhaustion ' . audit_json_encode($details), + false, + 'POLLER' + ); + audit_record_event('audit.authentication.ingestion.dropped', [ + 'event_category' => 'audit', + 'severity' => 'error', + 'action' => 'drop', + 'target_type' => 'user_log_row', + 'target_id' => $username, + 'operation_outcome' => 'failure', + 'outcome_reason' => 'maximum_retries_exhausted', + 'details' => $details + ]); +} + +function audit_cleanup_user_log_state(int $max_retries = 5, ?int $budget = null, bool $report_terminal = false): void { + if (!db_table_exists('audit_user_log_state')) { + return; + } + + $budget = max(1, min(5000, $budget ?? (int) read_config_option('audit_user_log_batch_size'))); + $watermark = max(0, (int) read_config_option('audit_user_log_watermark_epoch', true)); + $replay_floor = max(0, $watermark - 300); + + if ($report_terminal) { + $terminal_count = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) FROM audit_user_log_state WHERE audit_id = 0 AND retry_count >= ?', + [$max_retries] + ); + + if ($terminal_count > 0) { + cacti_log( + 'WARNING: Authentication audit has ' . $terminal_count . ' terminal retry marker(s)', + false, + 'POLLER' + ); + } + } + + // Reap at least one ingestion batch per poller cycle. Marker age is based on + // claim time, while source_epoch remains the immutable source-row identity. + db_execute_prepared('DELETE FROM audit_user_log_state + WHERE audit_id = 0 + AND retry_count >= ? + AND source_time < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY) + LIMIT ' . (int) $budget, + [$max_retries]); + + db_execute_prepared('DELETE FROM audit_user_log_state + WHERE audit_id > 0 + AND source_epoch < ? + AND source_time < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY) + LIMIT ' . (int) $budget, + [$replay_floor]); } /** @@ -921,30 +998,66 @@ function audit_user_log_source_hash(string $username, int $user_id, string $time * relying on the local-auth-only login_process hook. * * Deduplication is durable and database-backed: each processed user_log - * primary-key tuple (username, user_id, time) is recorded in - * audit_user_log_state as a deterministic SHA-256 hash. The audit event and - * state marker are committed atomically; a concurrent loser rolls back its - * duplicate event before any external delivery occurs. + * primary-key tuple (username, user_id, UNIX_TIMESTAMP(time)) is recorded in + * audit_user_log_state. Explicit typed columns define identity once; a marker + * with audit_id=0 claims the source row before event creation without relying + * on transactions that Cacti's per-statement retry layer cannot preserve. * - * Each cycle selects a bounded batch of recent user_log rows that have no - * state marker. This anti-join approach keeps failed inserts and late commits - * discoverable instead of advancing a high-water cursor past them. The - * retention cutoff prevents arbitrary historical backfill. + * Each cycle selects a bounded batch of stale retry markers followed by new + * rows above the high-water floor. Pending markers never lower that floor. */ function audit_poll_user_log(): void { - if (read_config_option('audit_enabled') != 'on') { - return; - } + $auth_enabled = read_config_option('audit_enabled') === 'on' && + read_config_option('audit_auth_log_enabled') === 'on'; + $last_state = (string) read_config_option('audit_auth_log_last_state', true); + + if (!$auth_enabled) { + if ($last_state !== 'off') { + set_config_option('audit_auth_log_last_state', 'off'); + } - if (read_config_option('audit_auth_log_enabled') != 'on') { return; } if (!function_exists('db_table_exists') || !db_table_exists('user_log')) { + audit_report_ingestion_unavailable('user_log_missing'); + return; } if (!db_table_exists('audit_user_log_state')) { + audit_report_ingestion_unavailable('audit_user_log_state_missing'); + + return; + } + + if (!audit_user_log_identity_supported()) { + audit_report_ingestion_unavailable('user_log_identity_unsupported'); + + return; + } + + if ($last_state !== 'on') { + if (!audit_user_log_indexes_available()) { + audit_report_ingestion_unavailable('user_log_indexes_unavailable'); + + return; + } + + $activation_epoch = (int) db_fetch_cell_prepared('SELECT UNIX_TIMESTAMP()'); + + if ($activation_epoch <= 0) { + audit_report_ingestion_unavailable('database_clock_unavailable'); + + return; + } + + set_config_option('audit_auth_log_last_state', 'on'); + set_config_option('audit_user_log_watermark_epoch', (string) $activation_epoch); + set_config_option('audit_user_log_activation_epoch', (string) $activation_epoch); + + // Begin on the next cycle so every selected row is strictly newer than + // the activation watermark. return; } @@ -962,111 +1075,230 @@ function audit_poll_user_log(): void { $retention = 90; } - $cutoff = audit_retention_cutoff($retention)->format('Y-m-d H:i:s'); - $rows = db_fetch_assoc_prepared( - 'SELECT ul.username, ul.user_id, ul.result, ul.ip, ul.time - FROM user_log AS ul - WHERE ul.time > ? - AND NOT EXISTS ( - SELECT 1 - FROM audit_user_log_state AS auls - WHERE auls.source_hash = SHA2( - CONCAT(ul.username, "|", ul.user_id, "|", ul.time), - 256 - ) - ) + $retention_epoch = audit_retention_cutoff($retention)->getTimestamp(); + $watermark = max(0, (int) read_config_option('audit_user_log_watermark_epoch', true)); + $replay_floor = $watermark > 0 ? $watermark - 300 : 0; + $activation_floor = max(0, (int) read_config_option('audit_user_log_activation_epoch', true)); + $lower_bound = max($retention_epoch, $replay_floor, $activation_floor); + $max_retries = 5; + $pending_limit = $batch_size > 1 ? max(1, intdiv($batch_size, 2)) : 1; + // MySQL LIMIT placeholders may be string-bound under emulated prepares. + // These integers are fixed or clamped above before interpolation. + $pending_rows = db_fetch_assoc_prepared( + 'SELECT ul.username, ul.user_id, ul.result, ul.ip, + UNIX_TIMESTAMP(ul.time) AS source_epoch, + auls.audit_id AS state_audit_id, + auls.retry_count + FROM audit_user_log_state AS auls + INNER JOIN user_log AS ul + ON ul.username = auls.source_username + AND ul.user_id = auls.source_user_id + AND UNIX_TIMESTAMP(ul.time) = auls.source_epoch + WHERE auls.audit_id = 0 + AND auls.retry_count < ? + AND auls.processed_time < DATE_SUB(UTC_TIMESTAMP(6), INTERVAL 5 MINUTE) ORDER BY ul.time ASC, ul.username ASC, ul.user_id ASC - LIMIT ' . $batch_size, - [$cutoff] + LIMIT ' . (int) $pending_limit, + [$max_retries] ); - if (!is_array($rows) || cacti_sizeof($rows) === 0) { + if ($pending_rows === false) { + audit_log_ingestion_warning('Authentication audit retry query failed'); + + return; + } + + $remaining = max(0, $batch_size - count($pending_rows)); + $new_rows = []; + + if ($remaining > 0) { + $new_rows = db_fetch_assoc_prepared( + 'SELECT ul.username, ul.user_id, ul.result, ul.ip, + UNIX_TIMESTAMP(ul.time) AS source_epoch, + auls.audit_id AS state_audit_id + FROM user_log AS ul + LEFT JOIN audit_user_log_state AS auls + ON auls.source_username = ul.username + AND auls.source_user_id = ul.user_id + AND auls.source_epoch = UNIX_TIMESTAMP(ul.time) + WHERE ul.time > FROM_UNIXTIME(?) + AND auls.source_username IS NULL + ORDER BY ul.time ASC, ul.username ASC, ul.user_id ASC + LIMIT ' . (int) $remaining, + [$lower_bound] + ); + + if ($new_rows === false) { + audit_log_ingestion_warning('Authentication audit new-row query failed'); + + return; + } + } + + $rows = array_merge($pending_rows, $new_rows); + + if ($rows === []) { return; } - $now_utc = audit_utc_time(); + $retry_failures = 0; + $retry_exhausted = 0; foreach ($rows as $row) { - $result = (int) $row['result']; - $user_id = (int) $row['user_id']; - $time = (string) $row['time']; - $username = (string) $row['username']; + $result = (int) $row['result']; + $user_id = (int) $row['user_id']; + $source_epoch = (int) $row['source_epoch']; + $username = (string) $row['username']; + + if (isset($row['state_audit_id'])) { + $claimed = db_execute_prepared('UPDATE audit_user_log_state + SET processed_time = UTC_TIMESTAMP(6), + retry_count = retry_count + 1 + WHERE source_username = ? + AND source_user_id = ? + AND source_epoch = ? + AND audit_id = 0 + AND processed_time < DATE_SUB(UTC_TIMESTAMP(6), INTERVAL 5 MINUTE)', + [$username, $user_id, $source_epoch]); + } else { + $claimed = db_execute_prepared('INSERT IGNORE INTO audit_user_log_state + (source_username, source_user_id, source_epoch, source_time, audit_id, retry_count, processed_time) + VALUES (?, ?, ?, UTC_TIMESTAMP(), 0, 0, UTC_TIMESTAMP(6))', + [$username, $user_id, $source_epoch]); + } - $source_hash = audit_user_log_source_hash($username, $user_id, $time); - $source_key = $username . '|' . $user_id . '|' . $time; + if (!$claimed) { + audit_log_ingestion_warning('Authentication audit source-row claim failed; ingestion cycle stopped'); - if (!db_execute_prepared('START TRANSACTION')) { + return; + } + + if (db_affected_rows() !== 1) { continue; } $descriptor = audit_user_log_event_descriptor($result, $user_id); + $event_uuid = audit_user_log_event_uuid($username, $user_id, $source_epoch); + $audit_id = (int) db_fetch_cell_prepared( + 'SELECT id FROM audit_log WHERE event_uuid = ?', + [$event_uuid] + ); + $created = false; - $audit_id = audit_record_event($descriptor['event_type'], [ - 'event_category' => 'authentication', - 'action' => $descriptor['action'], - 'severity' => $descriptor['severity'], - 'operation_outcome' => $descriptor['outcome'], - 'actor_type' => $user_id > 0 ? 'user' : 'anonymous', - 'target_type' => 'user_account', - 'target_id' => $user_id > 0 ? (string) $user_id : $username, - 'ip_address' => (string) ($row['ip'] ?? ''), - 'user_agent' => '', - 'page' => 'user_log.php', - 'event_time' => $time, - 'defer_delivery' => true, - 'details' => [ - 'username' => $username, - 'result_code' => $result, - 'source_table' => 'user_log', - 'descriptor' => $descriptor['details'] - ] - ]); + if ($audit_id <= 0) { + $audit_id = audit_record_event($descriptor['event_type'], [ + 'event_uuid' => $event_uuid, + 'event_category' => 'authentication', + 'action' => $descriptor['action'], + 'severity' => $descriptor['severity'], + 'operation_outcome' => $descriptor['outcome'], + 'actor_type' => $user_id > 0 ? 'user' : 'anonymous', + 'target_type' => 'user_account', + 'target_id' => $user_id > 0 ? (string) $user_id : $username, + 'ip_address' => (string) ($row['ip'] ?? ''), + 'user_agent' => '', + 'page' => 'user_log.php', + 'event_time' => gmdate('Y-m-d H:i:s', $source_epoch), + 'defer_delivery' => true, + 'details' => [ + 'username' => $username, + 'result_code' => $result, + 'source_table' => 'user_log', + 'descriptor' => $descriptor['details'] + ] + ]); + $created = $audit_id > 0; + } if ($audit_id <= 0) { - db_execute_prepared('ROLLBACK'); + $retry_increment = isset($row['state_audit_id']) ? 0 : 1; + db_execute_prepared('UPDATE audit_user_log_state + SET retry_count = retry_count + ?, + processed_time = UTC_TIMESTAMP(6) + WHERE source_username = ? + AND source_user_id = ? + AND source_epoch = ? + AND audit_id = 0', + [$retry_increment, $username, $user_id, $source_epoch]); + $retry_failures++; + + if ((int) ($row['retry_count'] ?? 0) + 1 >= $max_retries) { + $retry_exhausted++; + audit_report_dropped_user_log_row($username, $user_id, $source_epoch, $result); + } continue; } - $state_inserted = db_execute_prepared( - 'INSERT IGNORE INTO audit_user_log_state - (source_hash, source_key, source_time, audit_id, processed_time) - VALUES (?, ?, ?, ?, ?)', - [$source_hash, $source_key, $time, $audit_id, $now_utc] + $finalized = db_execute_prepared( + 'UPDATE audit_user_log_state + SET audit_id = ? + WHERE source_username = ? + AND source_user_id = ? + AND source_epoch = ? + AND audit_id = 0', + [$audit_id, $username, $user_id, $source_epoch] ); - if (!$state_inserted || db_affected_rows() !== 1) { - db_execute_prepared('ROLLBACK'); - - continue; - } + if (!$finalized || db_affected_rows() !== 1) { + if ($created) { + db_execute_prepared('DELETE FROM audit_log WHERE id = ?', [$audit_id]); + } - if (!db_execute_prepared('COMMIT')) { - db_execute_prepared('ROLLBACK'); + $retry_increment = isset($row['state_audit_id']) ? 0 : 1; + db_execute_prepared('UPDATE audit_user_log_state + SET retry_count = retry_count + ?, + processed_time = UTC_TIMESTAMP(6) + WHERE source_username = ? + AND source_user_id = ? + AND source_epoch = ? + AND audit_id = 0', + [$retry_increment, $username, $user_id, $source_epoch]); + $retry_failures++; + + if ((int) ($row['retry_count'] ?? 0) + 1 >= $max_retries) { + $retry_exhausted++; + audit_report_dropped_user_log_row($username, $user_id, $source_epoch, $result); + } continue; } + db_execute_prepared( + 'INSERT INTO settings (name, value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE value = GREATEST(CAST(value AS UNSIGNED), VALUES(value))', + ['audit_user_log_watermark_epoch', (string) $source_epoch] + ); + audit_deliver_external_event($audit_id); audit_enqueue_syslog_event($audit_id); } + + if ($retry_failures > 0) { + audit_log_ingestion_warning(sprintf( + 'Authentication audit retained %d source row(s) for retry; %d exhausted the %d-attempt limit', + $retry_failures, + $retry_exhausted, + $max_retries + )); + } } /** - * Detect brute-force login patterns by counting failed user_log entries - * within a rolling window. Emits a single critical audit event per window - * to avoid alert flooding. + * Detect a global failed-login volume anomaly within a rolling window. + * This intentionally describes aggregate installation-wide activity rather + * than attributing unrelated failures to one attacker. */ -function audit_detect_brute_force(): void { - if (read_config_option('audit_enabled') != 'on') { +function audit_detect_failed_login_volume(): void { + if (read_config_option('audit_enabled') !== 'on') { return; } - if (read_config_option('audit_auth_log_enabled') != 'on') { + if (read_config_option('audit_auth_log_enabled') !== 'on') { return; } - if (read_config_option('audit_brute_force_enabled') != 'on') { + if (read_config_option('audit_brute_force_enabled') !== 'on') { return; } @@ -1090,13 +1322,16 @@ function audit_detect_brute_force(): void { $threshold = 1000; } - $count = (int) db_fetch_cell_prepared( - 'SELECT COUNT(*) + $metrics = db_fetch_row_prepared( + 'SELECT COUNT(*) AS failed_attempts, + COUNT(DISTINCT username) AS distinct_usernames, + COUNT(DISTINCT ip) AS distinct_ips FROM user_log WHERE result = 0 AND time >= DATE_SUB(NOW(), INTERVAL ? MINUTE)', [$window] ); + $count = (int) ($metrics['failed_attempts'] ?? 0); if ($count < $threshold) { return; @@ -1131,17 +1366,21 @@ function audit_detect_brute_force(): void { return; } - $audit_id = audit_record_event('cacti.auth.brute_force_suspected', [ + $audit_id = audit_record_event('cacti.auth.failed_login_volume_anomaly', [ 'event_category' => 'authentication', - 'action' => 'brute_force_suspected', + 'action' => 'failed_login_volume_anomaly', 'severity' => 'critical', 'operation_outcome' => 'failure', 'actor_type' => 'system', - 'target_type' => 'authentication', + 'target_type' => 'authentication_environment', + 'target_id' => 'global', 'details' => [ - 'failed_attempts' => $count, - 'window_minutes' => $window, - 'threshold' => $threshold + 'scope' => 'global', + 'failed_attempts' => $count, + 'distinct_usernames' => (int) ($metrics['distinct_usernames'] ?? 0), + 'distinct_ips' => (int) ($metrics['distinct_ips'] ?? 0), + 'window_minutes' => $window, + 'threshold' => $threshold ] ]); @@ -1163,11 +1402,11 @@ function audit_detect_brute_force(): void { * @return mixed */ function audit_custom_denied(mixed $mode): mixed { - if (read_config_option('audit_enabled') != 'on') { + if (read_config_option('audit_enabled') !== 'on') { return $mode; } - if (read_config_option('audit_auth_log_enabled') != 'on') { + if (read_config_option('audit_auth_log_enabled') !== 'on') { return $mode; } @@ -1175,9 +1414,8 @@ function audit_custom_denied(mixed $mode): mixed { $referer = $_SERVER['HTTP_REFERER'] ?? ''; $user_id = (int) ($_SESSION['sess_user_id'] ?? 0); - // Record only the referer origin and path; strip the query string to - // avoid leaking tokens, reset hashes, OAuth state, or session - // identifiers into the audit log and external syslog consumers. + // Record only the referer origin. Paths and query strings can both contain + // tokens, reset hashes, OAuth state, or session identifiers. $safe_referer = ''; if ($referer !== '') { @@ -1192,10 +1430,6 @@ function audit_custom_denied(mixed $mode): mixed { $safe_ref .= ':' . $parsed['port']; } } - - if (isset($parsed['path'])) { - $safe_ref .= $parsed['path']; - } } $safe_referer = $safe_ref !== '' ? $safe_ref : '[unparseable]'; @@ -1220,6 +1454,33 @@ function audit_custom_denied(mixed $mode): mixed { return $mode; } +/** + * @param array $post + * @return array{syslog:bool,auth:bool} + */ +function audit_settings_field_groups(array $post): array { + $groups = ['syslog' => false, 'auth' => false]; + + foreach ($post as $name => $value) { + $name = (string) $name; + + if (str_starts_with($name, 'audit_syslog_')) { + $groups['syslog'] = true; + } elseif ( + str_starts_with($name, 'audit_auth_') || + str_starts_with($name, 'audit_brute_force_') || + str_starts_with($name, 'audit_log_external') || + $name === 'audit_enabled' || + $name === 'audit_retention' || + $name === 'audit_user_log_batch_size' + ) { + $groups['auth'] = true; + } + } + + return $groups; +} + function audit_enforce_syslog_settings_request(): void { $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); $method = $_SERVER['REQUEST_METHOD'] ?? ''; @@ -1235,22 +1496,9 @@ function audit_enforce_syslog_settings_request(): void { return; } - $has_syslog_fields = false; - $has_auth_fields = false; - - foreach ($post as $name => $value) { - $name = (string) $name; - - if (strpos($name, 'audit_syslog_') === 0) { - $has_syslog_fields = true; - } elseif ( - strpos($name, 'audit_auth_') === 0 || - strpos($name, 'audit_brute_force_') === 0 || - $name === 'audit_user_log_batch_size' - ) { - $has_auth_fields = true; - } - } + $groups = audit_settings_field_groups($post); + $has_syslog_fields = $groups['syslog']; + $has_auth_fields = $groups['auth']; if (!$has_syslog_fields && !$has_auth_fields) { return; @@ -1280,7 +1528,24 @@ function audit_enforce_syslog_settings_request(): void { ]); } - http_response_code(403); + raise_message( + 'audit_configuration_authorization', + __('Audit administration permission is required to save these settings.', 'audit'), + MESSAGE_LEVEL_ERROR + ); + header('Location: settings.php?tab=audit'); + exit; + } + + $enabling_auth = isset($post['audit_auth_log_enabled']) && $post['audit_auth_log_enabled'] === 'on'; + + if ($enabling_auth && (!audit_user_log_identity_supported() || !audit_user_log_indexes_available())) { + raise_message( + 'audit_authentication_prerequisites', + __('Authentication auditing was not enabled. Run the audit_auth_indexes.php CLI maintenance command first.', 'audit'), + MESSAGE_LEVEL_ERROR + ); + header('Location: settings.php?tab=audit'); exit; } diff --git a/audit_syslog.php b/audit_syslog.php index 46245bf..398c83d 100644 --- a/audit_syslog.php +++ b/audit_syslog.php @@ -710,7 +710,7 @@ function audit_syslog_send_event(array $event, array $config, mixed &$socket = n function audit_enqueue_syslog_event(int $audit_id): void { if (!audit_syslog_enabled() || - !db_table_exists('audit_log') || + !audit_log_table_available() || !db_table_exists('audit_syslog_delivery')) { return; } @@ -810,7 +810,7 @@ function audit_syslog_update_delivery(array $delivery, array $result, array $con } function audit_process_syslog_queue(): void { - if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) { + if (!audit_log_table_available() || !audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) { return; } diff --git a/phpstan/stubs/cacti.stubs.php b/phpstan/stubs/cacti.stubs.php index cc7c8bd..a133a45 100644 --- a/phpstan/stubs/cacti.stubs.php +++ b/phpstan/stubs/cacti.stubs.php @@ -161,6 +161,7 @@ function db_fetch_insert_id(string $table = ''): int { return 0; } +/** @phpstan-impure */ function db_affected_rows(mixed $db_conn = false): int { return 0; } @@ -173,6 +174,7 @@ function db_table_exists(string $table, bool $log = true, mixed $db_conn = false return false; } +/** @phpstan-impure */ function db_index_exists(string $table, string $index, bool $log = true, mixed $db_conn = false): bool { return false; } @@ -186,6 +188,7 @@ function read_config_option(string $name, bool $global = false): mixed { return false; } +/** @phpstan-impure */ function set_config_option(string $name, string $value): bool { return false; } @@ -246,6 +249,7 @@ function html_escape_request_var(string $name): string { // ----- Logging / Misc ------------------------------------------------ +/** @phpstan-impure */ function cacti_log(string $string, bool $output = false, string $environment = '', int $level = 1, bool $force = false): bool { return false; } diff --git a/setup.php b/setup.php index 7722254..34d81cf 100644 --- a/setup.php +++ b/setup.php @@ -48,18 +48,22 @@ function plugin_audit_install(): void { /** * Persist authentication auditing defaults without overwriting existing * administrator choices. Called on fresh install and upgrade so that - * ordinary-user logout and authorization-denied hooks work with the - * advertised default even though the configuration controls remain hidden - * from non-Audit-Admin users. + * existing installations begin at the current time with authentication + * auditing disabled until an Audit Log Admin explicitly opts in. */ function audit_persist_auth_defaults(): void { $defaults = [ - 'audit_auth_log_enabled' => 'on', - 'audit_brute_force_enabled' => 'on', + 'audit_auth_log_enabled' => 'off', + 'audit_auth_log_last_state' => 'off', + 'audit_brute_force_enabled' => 'off', 'audit_brute_force_window_minutes' => '5', 'audit_brute_force_threshold' => '10', 'audit_brute_force_last_alert' => '', - 'audit_user_log_batch_size' => '1000' + 'audit_user_log_batch_size' => '1000', + 'audit_user_log_watermark_epoch' => (string) time(), + 'audit_user_log_indexes_owned' => '', + 'audit_user_log_activation_epoch' => '0', + 'audit_auth_ingestion_last_alert' => '0' ]; foreach ($defaults as $name => $value) { @@ -106,7 +110,7 @@ function audit_setup_realms(bool $grant_installing_user = false): void { } } -function audit_remove_deprecated_realms(): void { +function audit_remove_obsolete_realms(): void { $realms = db_fetch_assoc_prepared('SELECT id FROM plugin_realms WHERE plugin = ? @@ -136,24 +140,73 @@ function audit_remove_deprecated_realms(): void { } } +/** + * @return list + */ +function audit_owned_setting_names(): array { + return [ + 'audit_enabled', + 'audit_retention', + 'audit_log_external', + 'audit_log_external_format', + 'audit_log_external_path', + 'audit_last_check', + 'audit_auth_log_enabled', + 'audit_auth_log_last_state', + 'audit_brute_force_enabled', + 'audit_brute_force_window_minutes', + 'audit_brute_force_threshold', + 'audit_brute_force_last_alert', + 'audit_user_log_batch_size', + 'audit_user_log_watermark_epoch', + 'audit_user_log_indexes_owned', + 'audit_user_log_activation_epoch', + 'audit_auth_ingestion_last_alert', + 'audit_syslog_enabled', + 'audit_syslog_receiver', + 'audit_syslog_port', + 'audit_syslog_transport', + 'audit_syslog_format', + 'audit_syslog_facility', + 'audit_syslog_application', + 'audit_syslog_node_id', + 'audit_syslog_timeout', + 'audit_syslog_udp_max_size', + 'audit_syslog_tls_ca_file', + 'audit_syslog_tls_client_cert', + 'audit_syslog_tls_client_key', + 'audit_syslog_retry_base', + 'audit_syslog_retry_max', + 'audit_syslog_max_attempts', + 'audit_syslog_batch_size', + 'audit_syslog_pending_age_warning', + 'audit_syslog_dead_letter_warning', + 'audit_syslog_health_state' + ]; +} + function plugin_audit_uninstall(): bool { + // Static DDL contains no values to bind; data deletion below remains prepared. + $indexes_removed = audit_remove_user_log_indexes(); db_execute('DROP TABLE IF EXISTS audit_user_log_state'); db_execute('DROP TABLE IF EXISTS audit_syslog_delivery'); db_execute('DROP TABLE IF EXISTS audit_log'); + $setting_names = audit_owned_setting_names(); + + if (!$indexes_removed) { + $setting_names = array_values(array_diff($setting_names, ['audit_user_log_indexes_owned'])); + } + db_execute_prepared( - 'DELETE FROM settings WHERE LEFT(name, 6) = ?', - ['audit_'] + 'DELETE FROM settings WHERE name IN (' . implode(', ', array_fill(0, count($setting_names), '?')) . ')', + $setting_names ); - return true; + return $indexes_removed; } function audit_is_console_page(string $url): bool { - if (strpos($url, 'audit.php') !== false) { - return true; - } - - return false; + return str_contains($url, 'audit.php'); } function plugin_audit_check_config(): bool { @@ -210,7 +263,7 @@ function audit_check_upgrade(): void { audit_setup_user_log_state_table(); audit_persist_auth_defaults(); audit_setup_realms(); - audit_remove_deprecated_realms(); + audit_remove_obsolete_realms(); db_execute_prepared('UPDATE plugin_config SET version = ? @@ -284,7 +337,8 @@ function audit_replicate_out(array $data): array { db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status', true, $rcnn_id); audit_upgrade_event_schema($rcnn_id); - // Replicate and migrate durable user_log deduplication state. + // Replicate the plugin-owned deduplication state. Core user_log indexes + // are local-only and are never left behind on remote collectors. audit_setup_user_log_state_table($rcnn_id); } @@ -292,14 +346,16 @@ function audit_replicate_out(array $data): array { } function audit_poller_bottom(): void { + $last_check = read_config_option('audit_last_check'); + $now = gmdate('Y-m-d'); + $is_daily = $last_check != $now; + + // Reclaim marker rows at the same maximum rate as ingestion so sustained + // unauthenticated login traffic cannot create an unbounded state backlog. + audit_cleanup_user_log_state(5, null, $is_daily); audit_retry_external_logs(); audit_process_syslog_queue(); - // Brute-force detection runs every poller cycle so short bursts are - // caught in near-real-time. Only alert emission is throttled inside the - // function via audit_brute_force_last_alert. - audit_detect_brute_force(); - // Authentication events are captured by polling Cacti's user_log table, // which is authoritative across all auth methods (local, LDAP, basic, // domains) and stable across the 1.2.x and develop branches. Ingestion @@ -308,10 +364,10 @@ function audit_poller_bottom(): void { // duplicate events across repeated and concurrent pollers. audit_poll_user_log(); - $last_check = read_config_option('audit_last_check'); - $now = gmdate('Y-m-d'); + // Detect aggregate failed-login volume after importing the current batch. + audit_detect_failed_login_volume(); - if ($last_check != $now) { + if ($is_daily) { $retention = read_config_option('audit_retention'); if ($retention > 0) { @@ -328,16 +384,6 @@ function audit_poller_bottom(): void { [$cutoff->format('Y-m-d H:i:s')]); $rows = db_affected_rows(); cacti_log('NOTE: Purged ' . $rows . ' Audit Log Records from Cacti', false, 'POLLER'); - - // Deduplication state intentionally survives audit_log deletion so - // recent user_log rows are not imported again. Markers older than - // this cutoff can be removed safely because polling never selects - // source rows outside the same retention window. - if (db_table_exists('audit_user_log_state')) { - db_execute_prepared('DELETE FROM audit_user_log_state - WHERE source_time < ?', - [$cutoff->format('Y-m-d H:i:s')]); - } } } @@ -404,21 +450,26 @@ function audit_setup_table(): bool { /** * Durable, database-backed deduplication table for user_log ingestion. * - * Each processed user_log primary-key tuple (username, user_id, time) is - * recorded as a deterministic SHA-256 hash so repeated and concurrent - * pollers cannot double-record the same source row. audit_id is deliberately - * not a foreign key: deduplication state must survive audit-log retention and - * manual purges, otherwise recent user_log rows would be imported again. + * The source tuple is stored in typed columns, so identity has one canonical + * representation and remains stable across session-timezone changes. audit_id + * is deliberately not a foreign key so state survives audit-log purges. The + * tuple mirrors user_log's own (username, user_id, time) primary key; Cacti + * cannot store two source rows with the same tuple. */ function audit_setup_user_log_state_table(mixed $cnn_id = false): void { + // DDL has no values to bind; Cacti's schema helpers use db_execute() for + // CREATE/ALTER statements and prepared calls for data queries. db_execute("CREATE TABLE IF NOT EXISTS `audit_user_log_state` ( - `source_hash` char(64) NOT NULL, - `source_key` varchar(160) NOT NULL DEFAULT '', - `source_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `audit_id` bigint(20) unsigned NOT NULL, - `processed_time` datetime(6) NOT NULL, - PRIMARY KEY (`source_hash`), - KEY `source_time_key` (`source_time`, `source_key`)) + `source_username` varchar(50) NOT NULL DEFAULT '0', + `source_user_id` mediumint(8) NOT NULL DEFAULT '0', + `source_epoch` bigint(20) unsigned NOT NULL, + `source_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `audit_id` bigint(20) unsigned NOT NULL, + `retry_count` int(10) unsigned NOT NULL DEFAULT '0', + `processed_time` datetime(6) NOT NULL, + PRIMARY KEY (`source_username`, `source_user_id`, `source_epoch`), + KEY `pending_retry` (`audit_id`, `retry_count`, `processed_time`), + KEY `source_time` (`source_time`)) ENGINE=InnoDB COMMENT='Durable deduplication state for user_log ingestion'", true, @@ -446,12 +497,119 @@ function audit_setup_user_log_state_table(mixed $cnn_id = false): void { $cnn_id ); } +} + +/** + * Add the access paths required by the per-cycle authentication queries. + */ +function audit_setup_user_log_indexes(mixed $cnn_id = false): bool { + if ($cnn_id !== false) { + return false; + } + + if (!db_table_exists('user_log', false, $cnn_id)) { + return false; + } + + $allowed = ['plugin_audit_time', 'plugin_audit_result_time']; + $owned = array_intersect( + array_filter(explode(',', (string) read_config_option('audit_user_log_indexes_owned', true))), + $allowed + ); + + $definitions = [ + 'plugin_audit_time' => ['time', 'username', 'user_id'], + 'plugin_audit_result_time' => ['result', 'time'] + ]; + + foreach ($definitions as $index => $columns) { + if (!db_index_exists('user_log', $index, false, $cnn_id)) { + // Journal intent before DDL so a timeout after ALTER cannot orphan a + // plugin-created index on the core table. + $owned[] = $index; + $owned = array_values(array_unique($owned)); + set_config_option('audit_user_log_indexes_owned', implode(',', $owned)); + db_add_index('user_log', 'INDEX', $index, $columns, true, $cnn_id); + } + } - db_execute('ALTER TABLE audit_user_log_state - ADD COLUMN IF NOT EXISTS source_key varchar(160) NOT NULL DEFAULT "" AFTER source_hash', + $owned = array_values(array_filter( + array_unique($owned), + static fn (string $index): bool => db_index_exists('user_log', $index, false, $cnn_id) + )); + set_config_option('audit_user_log_indexes_owned', implode(',', $owned)); + + return audit_user_log_indexes_available($cnn_id); +} + +/** @phpstan-impure */ +function audit_user_log_indexes_available(mixed $cnn_id = false): bool { + return $cnn_id === false && + db_table_exists('user_log', false, $cnn_id) && + db_index_exists('user_log', 'plugin_audit_time', false, $cnn_id) && + db_index_exists('user_log', 'plugin_audit_result_time', false, $cnn_id); +} + +function audit_user_log_identity_supported(mixed $cnn_id = false): bool { + $columns = db_fetch_assoc_prepared( + 'SELECT COLUMN_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND INDEX_NAME = ? + ORDER BY SEQ_IN_INDEX', + ['user_log', 'PRIMARY'], true, $cnn_id ); + + if ($columns === false) { + return false; + } + + return array_column($columns, 'COLUMN_NAME') === ['username', 'user_id', 'time']; +} + +function audit_remove_user_log_indexes(mixed $cnn_id = false): bool { + if ($cnn_id !== false) { + return false; + } + + if (!db_table_exists('user_log', false, $cnn_id)) { + set_config_option('audit_user_log_indexes_owned', ''); + + return true; + } + + $allowed = ['plugin_audit_time', 'plugin_audit_result_time']; + $owned = array_intersect( + array_filter(explode(',', (string) read_config_option('audit_user_log_indexes_owned', true))), + $allowed + ); + + $failed = []; + + foreach ($owned as $index) { + if (db_index_exists('user_log', $index, false, $cnn_id)) { + // DDL identifiers cannot be bound; the name is restricted to the + // static plugin-owned allowlist above before raw execution. + if (!db_execute('ALTER TABLE `user_log` DROP INDEX `' . $index . '`', true, $cnn_id)) { + $failed[] = $index; + } + } + } + + set_config_option('audit_user_log_indexes_owned', implode(',', $failed)); + + if ($failed !== []) { + cacti_log( + 'ERROR: Audit plugin could not remove owned user_log indexes: ' . implode(', ', $failed), + false, + 'POLLER' + ); + } + + return $failed === []; } function audit_setup_syslog_table(): void { @@ -632,7 +790,10 @@ function audit_config_arrays(): void { function audit_config_settings(): void { global $tabs, $settings, $item_rows, $audit_retentions; - $temp = [ + $temp = []; + + if (php_sapi_name() === 'cli' || audit_user_is_admin()) { + $temp = [ 'audit_header' => [ 'friendly_name' => __('Audit Log Settings', 'audit'), 'method' => 'spacer', @@ -673,9 +834,8 @@ function audit_config_settings(): void { 'default' => '/var/www/html/cacti/log/audit.log', 'max_length' => '255' ], - ]; + ]; - if (php_sapi_name() === 'cli' || audit_user_is_admin()) { $auth_settings = [ 'audit_auth_header' => [ 'friendly_name' => __('Authentication Auditing', 'audit'), @@ -683,18 +843,18 @@ function audit_config_settings(): void { ], 'audit_auth_log_enabled' => [ 'friendly_name' => __('Enable Authentication Auditing', 'audit'), - 'description' => __('Check this box to capture login, logout, token, password-change, and authorization-denied events by polling the Cacti user_log table and supported hooks.', 'audit'), + 'description' => __('Opt in to capture new login, logout, token, password-change, and authorization-denied events from this point forward.', 'audit'), 'method' => 'checkbox', - 'default' => 'on' + 'default' => 'off' ], 'audit_brute_force_enabled' => [ - 'friendly_name' => __('Enable Brute-force Detection', 'audit'), - 'description' => __('Check this box to emit a critical audit event when failed logins exceed the threshold within the configured window.', 'audit'), + 'friendly_name' => __('Enable Failed-login Volume Detection', 'audit'), + 'description' => __('Emit a global anomaly event when installation-wide failed-login volume exceeds the configured threshold.', 'audit'), 'method' => 'checkbox', - 'default' => 'on' + 'default' => 'off' ], 'audit_brute_force_window_minutes' => [ - 'friendly_name' => __('Brute-force Window (minutes)', 'audit'), + 'friendly_name' => __('Failed-login Window (minutes)', 'audit'), 'description' => __('Rolling window in minutes, from 1 through 1440, used to count failed logins.', 'audit'), 'method' => 'textbox', 'default' => '5', @@ -702,8 +862,8 @@ function audit_config_settings(): void { 'size' => '8' ], 'audit_brute_force_threshold' => [ - 'friendly_name' => __('Brute-force Threshold', 'audit'), - 'description' => __('Number of failed logins within the window, from 1 through 1000, that triggers a brute-force alert.', 'audit'), + 'friendly_name' => __('Failed-login Volume Threshold', 'audit'), + 'description' => __('Installation-wide failed-login count, from 1 through 1000, that triggers a global anomaly event.', 'audit'), 'method' => 'textbox', 'default' => '10', 'max_length' => '4', @@ -711,7 +871,7 @@ function audit_config_settings(): void { ], 'audit_user_log_batch_size' => [ 'friendly_name' => __('User Log Ingestion Batch Size', 'audit'), - 'description' => __('Maximum user_log rows ingested per poller cycle, from 1 through 5000. Larger batches process backlogs faster but increase poller runtime.', 'audit'), + 'description' => __('Maximum user_log rows ingested and expired markers reclaimed per poller cycle, from 1 through 5000. Marker state is retained for seven days, so larger batches increase both peak poller work and the bounded seven-day state-table size.', 'audit'), 'method' => 'textbox', 'default' => '1000', 'max_length' => '4', diff --git a/tests/auth_audit_coverage_test.php b/tests/auth_audit_coverage_test.php new file mode 100644 index 0000000..bc5793e --- /dev/null +++ b/tests/auth_audit_coverage_test.php @@ -0,0 +1,73 @@ + $_opcode) { + if ($line < $reflection->getStartLine() || $line > $reflection->getEndLine()) { + continue; + } + + $total++; + + if (isset($hit_lines[$line])) { + $covered++; + } else { + $missed[$function][] = $line; + } + } +} + +$percentage = $total === 0 ? 0.0 : ($covered / $total) * 100; + +if ($missed !== []) { + foreach ($missed as $function => $lines) { + fwrite(STDERR, sprintf('%s missed executable lines: %s%s', $function, implode(', ', $lines), PHP_EOL)); + } + + fwrite(STDERR, sprintf("Authentication audit line coverage: %.2f%% (%d/%d)\n", $percentage, $covered, $total)); + exit(1); +} + +printf("Authentication audit line coverage: 100.00%% (%d/%d)\n", $covered, $total); diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php index b069d5b..a243d2c 100644 --- a/tests/auth_audit_test.php +++ b/tests/auth_audit_test.php @@ -13,27 +13,45 @@ $audit_auth_config = [ 'audit_enabled' => 'on', 'audit_auth_log_enabled' => 'on', + 'audit_auth_log_last_state' => 'on', 'audit_brute_force_enabled' => 'on', 'audit_brute_force_window_minutes' => '5', 'audit_brute_force_threshold' => '10', 'audit_brute_force_last_alert' => '', 'audit_user_log_batch_size' => '1000', + 'audit_user_log_watermark_epoch' => '0', + 'audit_user_log_activation_epoch' => '0', + 'audit_auth_ingestion_last_alert' => '0', 'audit_retention' => '90' ]; -$audit_auth_recorded_events = []; -$audit_auth_user_log_rows = []; -$audit_auth_state_rows = []; -$audit_auth_failed_count = 0; -$audit_auth_set_options = []; -$audit_auth_settings_rows = []; -$audit_auth_insert_fails = false; -$audit_auth_fail_usernames = []; -$audit_auth_state_conflict = false; -$audit_auth_affected_rows = 0; -$audit_auth_transaction = null; -$audit_auth_log_exists = true; -$audit_auth_executed_sql = []; +$audit_auth_recorded_events = []; +$audit_auth_user_log_rows = []; +$audit_auth_state_rows = []; +$audit_auth_failed_metrics = ['failed_attempts' => 0, 'distinct_usernames' => 0, 'distinct_ips' => 0]; +$audit_auth_set_options = []; +$audit_auth_settings_rows = []; +$audit_auth_insert_fails = false; +$audit_auth_fail_usernames = []; +$audit_auth_state_conflict = false; +$audit_auth_affected_rows = 0; +$audit_auth_log_exists = true; +$audit_auth_executed_sql = []; +$audit_auth_fetches = []; +$audit_auth_missing_tables = []; +$audit_auth_fail_sql = []; +$audit_auth_retry_claims_ready = false; +$audit_auth_logs = []; +$audit_auth_fetch_fails = ''; +$audit_auth_index_actions = []; +$audit_auth_index_setup_ok = true; +$audit_auth_identity_ok = true; +$audit_auth_database_epoch = null; +$audit_auth_insert_id_override = null; + +function audit_test_source_key(string $username, int $user_id, int $source_epoch): string { + return $username . '|' . $user_id . '|' . $source_epoch; +} function read_config_option(string $name, bool $force = false): string { global $audit_auth_config, $audit_auth_settings_rows; @@ -52,35 +70,40 @@ function set_config_option(string $name, string $value): void { $audit_auth_set_options[$name][] = $value; } -function db_execute_prepared(string $sql, array $params = []): bool { - global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction, $audit_auth_executed_sql; +function audit_setup_user_log_indexes(): bool { + global $audit_auth_index_actions, $audit_auth_index_setup_ok; + $audit_auth_index_actions[] = 'setup'; - $audit_auth_executed_sql[] = $sql; + return $audit_auth_index_setup_ok; +} - if (strpos($sql, 'START TRANSACTION') !== false) { - $audit_auth_transaction = [ - 'events' => $audit_auth_recorded_events, - 'state' => $audit_auth_state_rows - ]; +function audit_user_log_indexes_available(): bool { + global $audit_auth_index_actions, $audit_auth_index_setup_ok; + $audit_auth_index_actions[] = 'check'; - return true; - } + return $audit_auth_index_setup_ok; +} - if (strpos($sql, 'ROLLBACK') !== false) { - if (is_array($audit_auth_transaction)) { - $audit_auth_recorded_events = $audit_auth_transaction['events']; - $audit_auth_state_rows = $audit_auth_transaction['state']; - } +function audit_user_log_identity_supported(): bool { + global $audit_auth_identity_ok; - $audit_auth_transaction = null; + return $audit_auth_identity_ok; +} - return true; - } +function audit_remove_user_log_indexes(): void { + global $audit_auth_index_actions; + $audit_auth_index_actions[] = 'remove'; +} + +function db_execute_prepared(string $sql, array $params = []): bool { + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_executed_sql, $audit_auth_fail_sql; - if (strpos($sql, 'COMMIT') !== false) { - $audit_auth_transaction = null; + $audit_auth_executed_sql[] = $sql; - return true; + foreach ($audit_auth_fail_sql as $fragment) { + if (str_contains($sql, $fragment)) { + return false; + } } if (strpos($sql, 'INSERT INTO audit_log') !== false) { @@ -92,24 +115,29 @@ function db_execute_prepared(string $sql, array $params = []): bool { return false; } - $audit_auth_recorded_events[] = ['sql' => $sql, 'params' => $params]; + $audit_auth_recorded_events[] = ['id' => count($audit_auth_recorded_events) + 1, 'sql' => $sql, 'params' => $params]; $audit_auth_affected_rows = 1; return true; } if (strpos($sql, 'INSERT IGNORE INTO audit_user_log_state') !== false) { - $hash = $params[0]; + $username = (string) $params[0]; + $user_id = (int) $params[1]; + $source_epoch = (int) $params[2]; + $key = audit_test_source_key($username, $user_id, $source_epoch); - if ($audit_auth_state_conflict || isset($audit_auth_state_rows[$hash])) { + if ($audit_auth_state_conflict || isset($audit_auth_state_rows[$key])) { $audit_auth_affected_rows = 0; } else { - $audit_auth_state_rows[$hash] = [ - 'source_hash' => $hash, - 'source_key' => $params[1], - 'source_time' => $params[2], - 'audit_id' => $params[3], - 'processed_time' => $params[4] + $audit_auth_state_rows[$key] = [ + 'source_username' => $username, + 'source_user_id' => $user_id, + 'source_epoch' => $source_epoch, + 'source_time' => time(), + 'audit_id' => 0, + 'retry_count' => 0, + 'processed_time' => '2026-07-25 00:00:00' ]; $audit_auth_affected_rows = 1; } @@ -117,6 +145,68 @@ function db_execute_prepared(string $sql, array $params = []): bool { return true; } + if (strpos($sql, 'UPDATE audit_user_log_state') !== false) { + $is_finalize = strpos($sql, 'SET audit_id = ?') !== false; + $is_retry_add = strpos($sql, 'retry_count = retry_count + ?') !== false; + + if ($is_finalize) { + $audit_id = (int) $params[0]; + $username = (string) $params[1]; + $user_id = (int) $params[2]; + $source_epoch = (int) $params[3]; + } elseif ($is_retry_add) { + $audit_id = 0; + $username = (string) $params[1]; + $user_id = (int) $params[2]; + $source_epoch = (int) $params[3]; + } else { + $audit_id = 0; + $username = (string) $params[0]; + $user_id = (int) $params[1]; + $source_epoch = (int) $params[2]; + } + + $key = audit_test_source_key($username, $user_id, $source_epoch); + + if (!isset($audit_auth_state_rows[$key]) || $audit_auth_state_rows[$key]['audit_id'] !== 0) { + $audit_auth_affected_rows = 0; + + return true; + } + + if ($is_finalize) { + $audit_auth_state_rows[$key]['audit_id'] = $audit_id; + } elseif (strpos($sql, 'retry_count = retry_count +') !== false) { + $audit_auth_state_rows[$key]['retry_count'] += $is_retry_add ? (int) $params[0] : 1; + } + + $audit_auth_affected_rows = 1; + + return true; + } + + if (strpos($sql, 'DELETE FROM audit_log WHERE id') !== false) { + $id = (int) ($params[0] ?? 0); + $audit_auth_recorded_events = array_values(array_filter( + $audit_auth_recorded_events, + static fn (array $event): bool => (int) ($event['id'] ?? 0) !== $id + )); + + return true; + } + + if (strpos($sql, 'INSERT INTO settings') !== false) { + $name = (string) ($params[0] ?? ''); + + if ($name === 'audit_user_log_watermark_epoch') { + $current = (int) ($audit_auth_settings_rows[$name] ?? 0); + $value = (int) ($params[1] ?? 0); + $audit_auth_settings_rows[$name] = (string) max($current, $value); + + return true; + } + } + if (strpos($sql, 'INSERT IGNORE INTO settings') !== false) { $name = (string) $params[0]; @@ -156,7 +246,26 @@ function db_execute_prepared(string $sql, array $params = []): bool { } if (strpos($sql, 'DELETE FROM audit_user_log_state') !== false) { - $audit_auth_state_rows = []; + $terminal = strpos($sql, 'audit_id = 0') !== false; + $removed = 0; + $limit = preg_match('/LIMIT (\d+)/', $sql, $matches) === 1 ? (int) $matches[1] : PHP_INT_MAX; + + foreach ($audit_auth_state_rows as $key => $state) { + $matches = $terminal + ? (int) $state['audit_id'] === 0 && (int) $state['retry_count'] >= (int) ($params[0] ?? 5) + : (int) $state['audit_id'] > 0 && (int) $state['source_epoch'] < (int) ($params[0] ?? 0); + + if ($matches) { + unset($audit_auth_state_rows[$key]); + $removed++; + + if ($removed >= $limit) { + break; + } + } + } + + $audit_auth_affected_rows = $removed; return true; } @@ -165,34 +274,24 @@ function db_execute_prepared(string $sql, array $params = []): bool { } function db_fetch_insert_id(): int { - global $audit_auth_recorded_events, $audit_auth_insert_fails; + global $audit_auth_recorded_events, $audit_auth_insert_fails, $audit_auth_insert_id_override; if ($audit_auth_insert_fails) { return 0; } + if ($audit_auth_insert_id_override !== null) { + return $audit_auth_insert_id_override; + } + return count($audit_auth_recorded_events); } function db_fetch_row_prepared(string $sql, array $params = []): array { - global $audit_auth_state_rows; - - if (strpos($sql, 'MAX(source_time)') !== false) { - if (empty($audit_auth_state_rows)) { - return []; - } - - $max_time = ''; - $max_hash = ''; - - foreach ($audit_auth_state_rows as $row) { - if ($row['source_time'] > $max_time || ($row['source_time'] === $max_time && ($row['source_key'] ?? '') > $max_hash)) { - $max_time = $row['source_time']; - $max_hash = $row['source_key'] ?? ''; - } - } + global $audit_auth_failed_metrics; - return ['max_time' => $max_time, 'max_key' => $max_hash]; + if (str_contains($sql, 'COUNT(*) AS failed_attempts')) { + return $audit_auth_failed_metrics; } return []; @@ -202,32 +301,47 @@ function db_fetch_row_prepared(string $sql, array $params = []): array { * SQL-interpreting stub: filters user_log rows by retention and durable * deduplication state, orders by (time, username, user_id), and applies LIMIT. */ -function db_fetch_assoc_prepared(string $sql, array $params = []): array { - global $audit_auth_user_log_rows, $audit_auth_state_rows; +function db_fetch_assoc_prepared(string $sql, array $params = []): array|false { + global $audit_auth_user_log_rows, $audit_auth_state_rows, $audit_auth_fetches, $audit_auth_retry_claims_ready, $audit_auth_fetch_fails; - if (strpos($sql, 'FROM user_log') === false) { + if (!str_contains($sql, 'user_log AS ul')) { return []; } - $cutoff = (string) ($params[0] ?? ''); - $filtered = []; + $is_pending = str_contains($sql, 'INNER JOIN user_log AS ul'); + + if ($audit_auth_fetch_fails === ($is_pending ? 'pending' : 'new')) { + return false; + } + + $audit_auth_fetches[] = ['sql' => $sql, 'params' => $params]; + $cutoff = $is_pending ? 0 : (int) ($params[0] ?? 0); + $max_retries = 5; + $limit = preg_match('/LIMIT (\d+)/', $sql, $limit_match) === 1 ? (int) $limit_match[1] : 1000; + $filtered = []; foreach ($audit_auth_user_log_rows as $row) { - $time = (string) $row['time']; - $hash = audit_user_log_source_hash( - (string) $row['username'], - (int) $row['user_id'], - $time - ); - - if ($time > $cutoff && !isset($audit_auth_state_rows[$hash])) { - $filtered[] = $row; + $source_epoch = isset($row['source_epoch']) + ? (int) $row['source_epoch'] + : (int) strtotime((string) $row['time'] . ' UTC'); + $key = audit_test_source_key((string) $row['username'], (int) $row['user_id'], $source_epoch); + $state = $audit_auth_state_rows[$key] ?? null; + + $selected = $is_pending + ? ($state !== null && (int) $state['audit_id'] === 0 && (int) $state['retry_count'] < $max_retries && $audit_auth_retry_claims_ready) + : ($source_epoch > $cutoff && $state === null); + + if ($selected) { + $row['source_epoch'] = $source_epoch; + $row['state_audit_id'] = $state['audit_id'] ?? null; + $row['retry_count'] = $state['retry_count'] ?? 0; + $filtered[] = $row; } } usort($filtered, function ($a, $b) { - if ($a['time'] !== $b['time']) { - return $a['time'] <=> $b['time']; + if ($a['source_epoch'] !== $b['source_epoch']) { + return $a['source_epoch'] <=> $b['source_epoch']; } if ($a['username'] !== $b['username']) { @@ -237,24 +351,30 @@ function db_fetch_assoc_prepared(string $sql, array $params = []): array { return $a['user_id'] <=> $b['user_id']; }); - $limit = preg_match('/LIMIT\\s+(\\d+)/i', $sql, $matches) - ? (int) $matches[1] - : 1000; - return array_slice($filtered, 0, $limit); } function db_fetch_cell_prepared(string $sql, array $params = []): int|string { - global $audit_auth_failed_count, $audit_auth_state_rows; + global $audit_auth_recorded_events, $audit_auth_database_epoch, $audit_auth_state_rows; - if (strpos($sql, 'COUNT(*)') !== false && strpos($sql, 'result = 0') !== false) { - return $audit_auth_failed_count; + if (str_contains($sql, 'SELECT UNIX_TIMESTAMP()')) { + return $audit_auth_database_epoch ?? time(); } - if (strpos($sql, 'SELECT 1 FROM audit_user_log_state') !== false) { - $hash = $params[0] ?? ''; + if (str_contains($sql, 'FROM audit_log WHERE event_uuid')) { + foreach ($audit_auth_recorded_events as $event) { + if (($event['params'][10] ?? null) === ($params[0] ?? null)) { + return (int) $event['id']; + } + } + } - return isset($audit_auth_state_rows[$hash]) ? 1 : 0; + if (str_contains($sql, 'COUNT(*) FROM audit_user_log_state')) { + return count(array_filter( + $audit_auth_state_rows, + static fn (array $state): bool => (int) $state['audit_id'] === 0 && + (int) $state['retry_count'] >= (int) ($params[0] ?? 5) + )); } return ''; @@ -267,7 +387,11 @@ function db_affected_rows(): int { } function db_table_exists(string $table): bool { - global $audit_auth_log_exists; + global $audit_auth_log_exists, $audit_auth_missing_tables; + + if (in_array($table, $audit_auth_missing_tables, true)) { + return false; + } if ($table === 'audit_log') { return $audit_auth_log_exists; @@ -301,6 +425,27 @@ function __(string $text, string $domain = ''): string { } function cacti_log(string $message, bool $also_print = false, string $log_type = '', int $level = 0): void { + global $audit_auth_logs; + + $audit_auth_logs[] = $message; +} + +function audit_test_log_count(): int { + global $audit_auth_logs; + + return count($audit_auth_logs); +} + +function audit_test_log_contains(string $needle): bool { + global $audit_auth_logs; + + foreach ($audit_auth_logs as $message) { + if (str_contains($message, $needle)) { + return true; + } + } + + return false; } function audit_test_assert_same(mixed $expected, mixed $actual, string $message): void { @@ -319,19 +464,67 @@ function audit_test_assert_true(bool $condition, string $message): void { } } +/** + * @return array + */ +function audit_test_last_event_params(): array { + global $audit_auth_recorded_events; + + $event = end($audit_auth_recorded_events); + + if (!is_array($event) || !isset($event['params']) || !is_array($event['params'])) { + fwrite(STDERR, 'Expected a recorded audit event.' . PHP_EOL); + exit(1); + } + + return $event['params']; +} + +/** + * @return array + */ +function audit_test_first_event_params(): array { + global $audit_auth_recorded_events; + + $events = $audit_auth_recorded_events; + $event = array_shift($events); + + if (!is_array($event) || !isset($event['params']) || !is_array($event['params'])) { + fwrite(STDERR, 'Expected a first recorded audit event.' . PHP_EOL); + exit(1); + } + + return $event['params']; +} + function audit_test_reset_state(): void { - global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_set_options, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction, $audit_auth_log_exists, $audit_auth_executed_sql; - $audit_auth_recorded_events = []; - $audit_auth_state_rows = []; - $audit_auth_set_options = []; - $audit_auth_settings_rows = []; - $audit_auth_insert_fails = false; - $audit_auth_fail_usernames = []; - $audit_auth_state_conflict = false; - $audit_auth_affected_rows = 0; - $audit_auth_transaction = null; - $audit_auth_log_exists = true; - $audit_auth_executed_sql = []; + global $audit_auth_config, $audit_auth_failed_metrics, $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_set_options, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_log_exists, $audit_auth_executed_sql, $audit_auth_fetches, $audit_auth_missing_tables, $audit_auth_fail_sql, $audit_auth_retry_claims_ready, $audit_auth_logs, $audit_auth_fetch_fails, $audit_auth_index_actions, $audit_auth_index_setup_ok, $audit_auth_identity_ok, $audit_auth_database_epoch, $audit_auth_insert_id_override; + $audit_auth_failed_metrics = ['failed_attempts' => 0, 'distinct_usernames' => 0, 'distinct_ips' => 0]; + $audit_auth_recorded_events = []; + $audit_auth_state_rows = []; + $audit_auth_set_options = []; + $audit_auth_settings_rows = []; + $audit_auth_insert_fails = false; + $audit_auth_fail_usernames = []; + $audit_auth_state_conflict = false; + $audit_auth_affected_rows = 0; + $audit_auth_log_exists = true; + $audit_auth_executed_sql = []; + $audit_auth_fetches = []; + $audit_auth_missing_tables = []; + $audit_auth_fail_sql = []; + $audit_auth_retry_claims_ready = false; + $audit_auth_logs = []; + $audit_auth_fetch_fails = ''; + $audit_auth_index_actions = []; + $audit_auth_index_setup_ok = true; + $audit_auth_identity_ok = true; + $audit_auth_database_epoch = null; + $audit_auth_insert_id_override = null; + $audit_auth_config['audit_user_log_watermark_epoch'] = '0'; + $audit_auth_config['audit_user_log_activation_epoch'] = '0'; + $audit_auth_config['audit_auth_ingestion_last_alert'] = '0'; + $audit_auth_config['audit_auth_log_last_state'] = 'on'; } // --------------------------------------------------------------------------- @@ -364,6 +557,10 @@ function audit_test_reset_state(): void { audit_test_assert_same('unknown', $unknown['outcome'], 'Unsupported result codes must carry an unknown outcome.'); audit_test_assert_same(99, $unknown['details']['unsupported_result_code'], 'The unsupported code must be recorded in details.'); +$source_uuid = audit_user_log_event_uuid('alice', 5, 1721926800); +audit_test_assert_same($source_uuid, audit_user_log_event_uuid('alice', 5, 1721926800), 'A source row must always receive the same event UUID.'); +audit_test_assert_true(preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $source_uuid) === 1, 'Source event UUIDs must be valid deterministic version-5 identifiers.'); + // --------------------------------------------------------------------------- // 2. audit_poll_user_log() records one event per new user_log row // --------------------------------------------------------------------------- @@ -399,7 +596,21 @@ function audit_test_reset_state(): void { $audit_auth_recorded_events = []; audit_poll_user_log(); audit_test_assert_same(0, count($audit_auth_recorded_events), 'audit_poll_user_log() must be gated by audit_auth_log_enabled.'); +audit_test_assert_same([], $audit_auth_index_actions, 'Checkbox transitions must not run DDL against core user_log.'); $audit_auth_config['audit_auth_log_enabled'] = 'on'; +$activation_before = time(); +$audit_auth_index_setup_ok = false; +audit_poll_user_log(); +audit_test_assert_same('off', read_config_option('audit_auth_log_last_state'), 'Missing indexes must keep authentication auditing fail-closed.'); +audit_test_assert_true(audit_test_log_count() > 0, 'Failed index setup must emit an operational warning.'); +$degraded_params = audit_test_last_event_params(); +audit_test_assert_same('audit.authentication.ingestion.unavailable', $degraded_params[12] ?? null, 'Failed activation must create an admin-visible audit event.'); +$audit_auth_index_setup_ok = true; +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Enabling authentication auditing must start at the activation boundary without backfill.'); +audit_test_assert_true((int) read_config_option('audit_user_log_watermark_epoch') >= $activation_before, 'Enable transition must advance the ingestion watermark to the current time.'); +audit_test_assert_same(['check', 'check'], $audit_auth_index_actions, 'The poller must check prerequisites without running DDL against core user_log.'); // --------------------------------------------------------------------------- // 3. More than 1,000 rows sharing one timestamp: bounded anti-join paging @@ -460,21 +671,24 @@ function audit_test_reset_state(): void { ]; // Simulate a concurrent poller that already recorded the state row. -$hash = audit_user_log_source_hash('concurrent', 42, '2026-07-25 14:00:00'); -$audit_auth_state_rows[$hash] = [ - 'source_hash' => $hash, - 'source_key' => 'concurrent|42|2026-07-25 14:00:00', - 'source_time' => '2026-07-25 14:00:00', - 'audit_id' => 999, - 'processed_time' => '2026-07-25 14:00:01' +$concurrent_epoch = (int) strtotime('2026-07-25 14:00:00 UTC'); +$concurrent_key = audit_test_source_key('concurrent', 42, $concurrent_epoch); +$audit_auth_state_rows[$concurrent_key] = [ + 'source_username' => 'concurrent', + 'source_user_id' => 42, + 'source_epoch' => $concurrent_epoch, + 'source_time' => $concurrent_epoch, + 'audit_id' => 999, + 'retry_count' => 0, + 'processed_time' => '2026-07-25 14:00:01' ]; audit_poll_user_log(); audit_test_assert_same(0, count($audit_auth_recorded_events), 'A row already claimed by a concurrent poller must not be double-recorded.'); -// Simulate both pollers selecting the row before either state marker is -// visible. The losing transaction must roll back its audit event when its -// INSERT IGNORE reports that another poller won the unique source hash. +// Simulate both pollers selecting the row before either claim is visible. The +// losing poller must skip event creation when INSERT IGNORE reports that the +// other poller won the unique source hash. audit_test_reset_state(); $audit_auth_user_log_rows = [ ['username' => 'racing', 'user_id' => 43, 'result' => 1, 'ip' => '10.0.0.100', 'time' => '2026-07-25 14:01:00'] @@ -482,7 +696,7 @@ function audit_test_reset_state(): void { $audit_auth_state_conflict = true; audit_poll_user_log(); -audit_test_assert_same(0, count($audit_auth_recorded_events), 'A concurrent state-insert loser must roll back its duplicate audit event.'); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A concurrent claim loser must not create a duplicate audit event.'); audit_test_assert_same(0, count($audit_auth_state_rows), 'A concurrent state-insert loser must not create a state marker.'); // --------------------------------------------------------------------------- @@ -492,22 +706,96 @@ function audit_test_reset_state(): void { audit_test_reset_state(); $audit_auth_user_log_rows = [ ['username' => 'failinsert', 'user_id' => 50, 'result' => 1, 'ip' => '10.0.0.50', 'time' => '2026-07-25 15:00:00'], - ['username' => 'later', 'user_id' => 51, 'result' => 1, 'ip' => '10.0.0.51', 'time' => '2026-07-25 15:00:01'] + ['username' => 'later', 'user_id' => 51, 'result' => 1, 'ip' => '10.0.0.51', 'time' => '2026-07-25 16:00:00'] ]; $audit_auth_fail_usernames = ['failinsert']; audit_poll_user_log(); audit_test_assert_same(1, count($audit_auth_recorded_events), 'A later row must still be recorded when an earlier audit insert fails.'); -audit_test_assert_same(1, count($audit_auth_state_rows), 'Only the successful later row may receive a state marker.'); +audit_test_assert_same(2, count($audit_auth_state_rows), 'The failed row must retain a retry marker while the later row is completed.'); +audit_test_assert_true(audit_test_log_count() > 0, 'A failed audit insert must emit an operational warning.'); // Retry after the insert recovers: the earlier row must remain discoverable // even though a later source time has already been processed. -$audit_auth_fail_usernames = []; -$audit_auth_recorded_events = []; +$audit_auth_fail_usernames = []; +$audit_auth_recorded_events = []; +$audit_auth_retry_claims_ready = true; audit_poll_user_log(); -audit_test_assert_same(1, count($audit_auth_recorded_events), 'A failed row behind a later success must be retried on the next cycle.'); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'A failed row more than five minutes behind a later success must remain retryable.'); audit_test_assert_same(2, count($audit_auth_state_rows), 'The retried row must produce its durable state marker.'); +// A poison retry at its last attempt must not consume the entire batch or +// prevent a newer row from advancing. +audit_test_reset_state(); +$audit_auth_config['audit_user_log_batch_size'] = '2'; +$poison_epoch = (int) strtotime('2026-07-25 16:30:00 UTC'); +$audit_auth_user_log_rows = [ + ['username' => 'poison', 'user_id' => 52, 'result' => 1, 'ip' => '10.0.0.52', 'time' => '2026-07-25 16:30:00', 'source_epoch' => $poison_epoch], + ['username' => 'healthy', 'user_id' => 53, 'result' => 1, 'ip' => '10.0.0.53', 'time' => '2026-07-25 16:31:00'] +]; +$audit_auth_state_rows[audit_test_source_key('poison', 52, $poison_epoch)] = [ + 'source_username' => 'poison', 'source_user_id' => 52, 'source_epoch' => $poison_epoch, + 'source_time' => $poison_epoch, 'audit_id' => 0, 'retry_count' => 4, + 'processed_time' => '2026-07-25 00:00:00' +]; +$audit_auth_fail_usernames = ['poison']; +$audit_auth_retry_claims_ready = true; +audit_poll_user_log(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'A poison retry must not starve a healthy new row when audit_log remains unavailable for the dropped row.'); +$dropped_params = audit_test_last_event_params(); +audit_test_assert_same('cacti.auth.login.credentials_accepted', $dropped_params[12] ?? null, 'The healthy row must still be processed after dropped-row evidence is recorded.'); +audit_test_assert_same(5, $audit_auth_state_rows[audit_test_source_key('poison', 52, $poison_epoch)]['retry_count'], 'A poison marker must become terminal after five attempts.'); +audit_test_assert_true(audit_test_log_count() === 2, 'A terminal failure must emit per-row evidence plus the per-cycle summary.'); +audit_test_assert_true(audit_test_log_contains('"username":"poison"'), 'Primary drop evidence must identify the source tuple in cacti.log.'); + +audit_cleanup_user_log_state(5, null, true); +$healthy_epoch = (int) strtotime('2026-07-25 16:31:00 UTC'); +$healthy_key = audit_test_source_key('healthy', 53, $healthy_epoch); +audit_test_assert_same(1, count($audit_auth_state_rows), 'Cleanup must reap terminal markers while retaining completed markers inside the replay floor.'); +audit_test_assert_true(isset($audit_auth_state_rows[$healthy_key]), 'A replay-floor marker must remain to prevent duplicate external delivery.'); +audit_test_assert_true(audit_test_log_contains('terminal retry marker'), 'Daily cleanup must surface the live terminal-marker count.'); + +foreach ([1000, 2000] as $old_epoch) { + $audit_auth_state_rows[audit_test_source_key('old-' . $old_epoch, 1, $old_epoch)] = [ + 'source_username' => 'old-' . $old_epoch, 'source_user_id' => 1, 'source_epoch' => $old_epoch, + 'source_time' => 1, 'audit_id' => $old_epoch, 'retry_count' => 0, + 'processed_time' => '2026-07-01 00:00:00' + ]; +} + +audit_cleanup_user_log_state(5, 1); +audit_test_assert_same(2, count($audit_auth_state_rows), 'Per-cycle cleanup must honor its rate-proportional batch budget.'); +audit_cleanup_user_log_state(5, 1); +audit_test_assert_same(1, count($audit_auth_state_rows), 'Repeated poller cleanup must keep pace with an equal ingestion budget.'); + +$audit_auth_missing_tables = ['audit_user_log_state']; +audit_cleanup_user_log_state(); +$audit_auth_missing_tables = []; +$audit_auth_config['audit_user_log_batch_size'] = '1000'; + +// Recovering a crash after audit insertion must finalize the deterministic +// source event instead of inserting a duplicate. +audit_test_reset_state(); +$crash_epoch = (int) strtotime('2026-07-25 16:40:00 UTC'); +$crash_uuid = audit_user_log_event_uuid('crash-safe', 54, $crash_epoch); +$crash_params = array_fill(0, 25, null); +$crash_params[10] = $crash_uuid; +$audit_auth_recorded_events = [['id' => 77, 'sql' => '', 'params' => $crash_params]]; +$audit_auth_user_log_rows = [[ + 'username' => 'crash-safe', 'user_id' => 54, 'result' => 1, 'ip' => '10.0.0.54', + 'time' => '2026-07-25 16:40:00', 'source_epoch' => $crash_epoch +]]; +$crash_key = audit_test_source_key('crash-safe', 54, $crash_epoch); +$audit_auth_state_rows[$crash_key] = [ + 'source_username' => 'crash-safe', 'source_user_id' => 54, 'source_epoch' => $crash_epoch, + 'source_time' => $crash_epoch, 'audit_id' => 0, 'retry_count' => 1, + 'processed_time' => '2026-07-25 00:00:00' +]; +$audit_auth_retry_claims_ready = true; +audit_poll_user_log(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Crash recovery must not duplicate an existing deterministic source event.'); +audit_test_assert_same(77, $audit_auth_state_rows[$crash_key]['audit_id'], 'Crash recovery must finalize the marker with the existing event ID.'); + // --------------------------------------------------------------------------- // 7. Retention policy excludes arbitrary historical rows // --------------------------------------------------------------------------- @@ -523,7 +811,7 @@ function audit_test_reset_state(): void { $recorded_usernames = []; foreach ($audit_auth_recorded_events as $event) { - $details = json_decode($event['params'][24], true); + $details = json_decode((string) ($event['params'][24] ?? ''), true); if (is_array($details) && isset($details['username'])) { $recorded_usernames[] = $details['username']; @@ -535,54 +823,105 @@ function audit_test_reset_state(): void { $audit_auth_config['audit_retention'] = '90'; // --------------------------------------------------------------------------- -// 8. Brute-force: exact threshold, throttle boundary, concurrency +// 8. UTC event time, stable identity, retention changes, and bounded scans +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$stable_epoch = time() - 60; +$audit_auth_user_log_rows = [[ + 'username' => 'timezone-stable', + 'user_id' => 70, + 'result' => 1, + 'ip' => '192.0.2.70', + 'time' => '2026-08-28 01:00:00', + 'source_epoch' => $stable_epoch +]]; + +audit_poll_user_log(); +$timezone_params = audit_test_last_event_params(); +audit_test_assert_same(gmdate('Y-m-d H:i:s', $stable_epoch), $timezone_params[6] ?? null, 'Authentication event time must be normalized from the source epoch to UTC.'); +audit_test_assert_same((string) $stable_epoch, read_config_option('audit_user_log_watermark_epoch'), 'Successful ingestion must advance the durable high-water mark.'); + +// The same TIMESTAMP rendered in a different session timezone retains the +// same epoch and therefore the same durable identity. +$audit_auth_user_log_rows[0]['time'] = '2026-08-27 18:00:00'; +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same([], $audit_auth_recorded_events, 'A session-timezone change must not re-ingest the same source row.'); + +// A marker older than the fixed marker horizon may be retired. Raising audit +// retention still cannot replay it because the high-water replay floor wins. +$audit_auth_state_rows = []; +$historical_epoch = $stable_epoch - 86400; +$audit_auth_user_log_rows = [[ + 'username' => 'historical', + 'user_id' => 71, + 'result' => 0, + 'ip' => '192.0.2.71', + 'time' => '2026-08-26 18:00:00', + 'source_epoch' => $historical_epoch +]]; +$audit_auth_config['audit_retention'] = '365'; +audit_poll_user_log(); +audit_test_assert_same([], $audit_auth_recorded_events, 'Increasing retention must not replay rows behind the durable high-water floor.'); +$last_fetch = end($audit_auth_fetches); +audit_test_assert_same($stable_epoch - 300, is_array($last_fetch) ? ($last_fetch['params'][0] ?? null) : null, 'Steady-state ingestion must scan only the bounded replay grace window.'); +$audit_auth_config['audit_retention'] = '90'; + +// --------------------------------------------------------------------------- +// 9. Global failed-login anomaly: threshold, identity counts, throttling // --------------------------------------------------------------------------- audit_test_reset_state(); // Below threshold: no emit. -$audit_auth_failed_count = 9; +$audit_auth_failed_metrics = ['failed_attempts' => 9, 'distinct_usernames' => 9, 'distinct_ips' => 9]; $audit_auth_recorded_events = []; -audit_detect_brute_force(); +audit_detect_failed_login_volume(); audit_test_assert_same(0, count($audit_auth_recorded_events), 'Below threshold must not emit.'); // Exactly at threshold: emit. -$audit_auth_failed_count = 10; +$audit_auth_failed_metrics = ['failed_attempts' => 10, 'distinct_usernames' => 7, 'distinct_ips' => 4]; $audit_auth_recorded_events = []; -audit_detect_brute_force(); +audit_detect_failed_login_volume(); audit_test_assert_same(1, count($audit_auth_recorded_events), 'Exactly at threshold must emit even when the throttle setting row did not previously exist.'); audit_test_assert_true(read_config_option('audit_brute_force_last_alert') !== '', 'Brute-force detection must initialize its throttle setting row.'); -audit_test_assert_same('cacti.auth.brute_force_suspected', $audit_auth_recorded_events[0]['params'][12], 'Brute-force event type must be correct.'); -audit_test_assert_same('critical', $audit_auth_recorded_events[0]['params'][14], 'Brute-force must be critical.'); +audit_test_assert_same('cacti.auth.failed_login_volume_anomaly', $audit_auth_recorded_events[0]['params'][12], 'The event must describe a global failed-login anomaly.'); +audit_test_assert_same('critical', $audit_auth_recorded_events[0]['params'][14], 'The failed-login anomaly must be critical.'); +audit_test_assert_same('global', $audit_auth_recorded_events[0]['params'][17], 'The event must explicitly identify global scope.'); +$anomaly_details = json_decode($audit_auth_recorded_events[0]['params'][24], true); +audit_test_assert_same('global', $anomaly_details['scope'], 'The event details must explicitly identify global scope.'); +audit_test_assert_same(7, $anomaly_details['distinct_usernames'], 'The event must report distinct affected usernames.'); +audit_test_assert_same(4, $anomaly_details['distinct_ips'], 'The event must report distinct source IPs.'); // Within window: throttled (atomic UPDATE claims nothing). -$audit_auth_failed_count = 12; +$audit_auth_failed_metrics = ['failed_attempts' => 12, 'distinct_usernames' => 8, 'distinct_ips' => 5]; $audit_auth_recorded_events = []; -audit_detect_brute_force(); +audit_detect_failed_login_volume(); audit_test_assert_same(0, count($audit_auth_recorded_events), 'Within the window, the atomic claim must throttle the second alert.'); // Concurrent check: second poller's UPDATE affects 0 rows. -$audit_auth_failed_count = 12; +$audit_auth_failed_metrics = ['failed_attempts' => 12, 'distinct_usernames' => 8, 'distinct_ips' => 5]; $audit_auth_recorded_events = []; -audit_detect_brute_force(); +audit_detect_failed_login_volume(); audit_test_assert_same(0, count($audit_auth_recorded_events), 'A concurrent poller must not emit a duplicate alert.'); // Failed audit insert releases the slot. audit_test_reset_state(); $audit_auth_settings_rows['audit_brute_force_last_alert'] = ''; -$audit_auth_failed_count = 10; +$audit_auth_failed_metrics = ['failed_attempts' => 10, 'distinct_usernames' => 2, 'distinct_ips' => 1]; $audit_auth_insert_fails = true; $audit_auth_recorded_events = []; -audit_detect_brute_force(); +audit_detect_failed_login_volume(); audit_test_assert_same('', $audit_auth_settings_rows['audit_brute_force_last_alert'] ?? '', 'A failed audit insert must release the alert slot for retry.'); $audit_auth_insert_fails = false; // Disabled must not emit. $audit_auth_config['audit_brute_force_enabled'] = 'off'; -$audit_auth_failed_count = 50; +$audit_auth_failed_metrics = ['failed_attempts' => 50, 'distinct_usernames' => 40, 'distinct_ips' => 30]; $audit_auth_settings_rows['audit_brute_force_last_alert'] = ''; $audit_auth_recorded_events = []; -audit_detect_brute_force(); +audit_detect_failed_login_volume(); audit_test_assert_same(0, count($audit_auth_recorded_events), 'Disabled brute-force detection must not emit.'); $audit_auth_config['audit_brute_force_enabled'] = 'on'; @@ -592,7 +931,7 @@ function audit_test_reset_state(): void { $_SESSION['sess_user_id'] = 5; $_SERVER['SCRIPT_NAME'] = '/cacti/host.php'; -$_SERVER['HTTP_REFERER'] = 'https://cacti.example.com/index.php?token=secret&reset_hash=abc123'; +$_SERVER['HTTP_REFERER'] = 'https://cacti.example.com/reset/secret-path?token=secret&reset_hash=abc123'; audit_test_reset_state(); $returned = audit_custom_denied('OPER_MODE_NATIVE'); @@ -601,7 +940,7 @@ function audit_test_reset_state(): void { audit_test_assert_same('cacti.auth.authorization.denied', $audit_auth_recorded_events[0]['params'][12], 'Denied event type must be correct.'); $details_json = $audit_auth_recorded_events[0]['params'][24]; $details = json_decode($details_json, true); -audit_test_assert_same('https://cacti.example.com/index.php', $details['referer_origin'], 'Referer query string must be stripped.'); +audit_test_assert_same('https://cacti.example.com', $details['referer_origin'], 'Referer paths and query strings must be stripped.'); audit_test_assert_true(strpos($details_json, 'secret') === false, 'The referer token must not appear in details.'); audit_test_assert_true(strpos($details_json, 'abc123') === false, 'The reset hash must not appear in details.'); @@ -627,19 +966,21 @@ function audit_test_reset_state(): void { $audit_auth_recorded_events = []; audit_logout_post_session_destroy(); audit_test_assert_same(1, count($audit_auth_recorded_events), 'Post-destroy must record one completed event.'); -audit_test_assert_same('authentication.logout.completed', $audit_auth_recorded_events[0]['params'][12], 'Post-destroy event type must be correct.'); -audit_test_assert_same(5, $audit_auth_recorded_events[0]['params'][1], 'Post-destroy must carry the stashed user_id.'); +$logout_params = audit_test_last_event_params(); +audit_test_assert_same('authentication.logout.completed', $logout_params[12] ?? null, 'Post-destroy event type must be correct.'); +audit_test_assert_same(5, $logout_params[1] ?? null, 'Post-destroy must carry the stashed user_id.'); // Empty stash: no record. $audit_auth_recorded_events = []; audit_logout_post_session_destroy(); audit_test_assert_same(0, count($audit_auth_recorded_events), 'Post-destroy must not record when the stash is empty.'); -// Master switch off: pre-destroy must not record. +// The pre-existing pre-destroy logout event remains available when the new +// authentication-ingestion feature is disabled. $audit_auth_config['audit_auth_log_enabled'] = 'off'; $audit_auth_recorded_events = []; audit_logout_pre_session_destroy(); -audit_test_assert_same(0, count($audit_auth_recorded_events), 'Pre-destroy must not record when auth auditing is disabled.'); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Upgrades must preserve the pre-existing logout event when new auth ingestion is disabled.'); $audit_auth_config['audit_auth_log_enabled'] = 'on'; // --------------------------------------------------------------------------- @@ -685,9 +1026,139 @@ function audit_test_reset_state(): void { && strpos($functions_source, 'audit_admin_required') !== false, 'The unauthorized auth-settings save must record an audit_admin_required denied event.' ); -audit_test_assert_true( - preg_match('/\\$name\\s*===\\s*[\'"]audit_user_log_batch_size[\'"]/', $functions_source) === 1, - 'The user_log ingestion batch-size setting must receive the same Audit Log Admin protection as authentication settings.' +audit_test_assert_same( + ['syslog' => false, 'auth' => true], + audit_settings_field_groups(['audit_enabled' => 'off', 'audit_retention' => '30']), + 'The master audit controls must require Audit Log Admin.' ); +audit_test_assert_same( + ['syslog' => false, 'auth' => true], + audit_settings_field_groups(['audit_log_external_path' => '/tmp/audit.log']), + 'The external file controls must require Audit Log Admin.' +); +audit_test_assert_same( + ['syslog' => false, 'auth' => true], + audit_settings_field_groups(['audit_auth_log_enabled' => 'on', 'audit_user_log_batch_size' => '100']), + 'New authentication settings must require Audit Log Admin.' +); +audit_test_assert_same( + ['syslog' => true, 'auth' => false], + audit_settings_field_groups(['audit_syslog_enabled' => 'on']), + 'Remote Syslog settings must require Audit Log Admin.' +); + +// --------------------------------------------------------------------------- +// 13. Guard, bounds, and database-failure branches +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_config['audit_auth_log_last_state'] = 'off'; +$audit_auth_identity_ok = false; +audit_poll_user_log(); +audit_test_assert_true(audit_test_log_count() > 0, 'An unsupported user_log primary key must fail closed with an operator signal.'); + +foreach ([0, -1] as $invalid_insert_id) { + audit_test_reset_state(); + $audit_auth_insert_id_override = $invalid_insert_id; + audit_test_assert_same(0, audit_record_event('audit.test.invalid_insert_id'), 'A non-positive insert ID must fail closed after an otherwise successful insert.'); +} + +audit_test_reset_state(); +$audit_auth_config['audit_auth_log_last_state'] = 'off'; +$audit_auth_database_epoch = 0; +audit_poll_user_log(); +audit_test_assert_true(audit_test_log_count() > 0, 'An unavailable database clock must fail closed with an operator signal.'); + +audit_test_reset_state(); +$audit_auth_config['audit_enabled'] = 'off'; +audit_poll_user_log(); +audit_test_assert_same('off', read_config_option('audit_auth_log_last_state'), 'Disabling the master audit switch must transition authentication ingestion off.'); +audit_test_assert_same([], $audit_auth_index_actions, 'Disabling the master switch must not run core-table DDL.'); +audit_detect_failed_login_volume(); +audit_test_assert_same('MODE', audit_custom_denied('MODE'), 'The master switch must gate denied-event auditing.'); +audit_logout_post_session_destroy(); +audit_test_reset_state(); +$audit_auth_config['audit_enabled'] = 'on'; + +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +audit_detect_failed_login_volume(); +audit_logout_post_session_destroy(); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +$audit_auth_missing_tables = ['user_log']; +audit_poll_user_log(); +audit_test_assert_true(audit_test_log_count() > 0, 'Missing user_log must emit an operational warning.'); +audit_detect_failed_login_volume(); +$audit_auth_missing_tables = ['audit_user_log_state']; +audit_poll_user_log(); +$audit_auth_missing_tables = []; +$audit_auth_user_log_rows = []; + +$audit_auth_fetch_fails = 'pending'; +audit_poll_user_log(); +audit_test_assert_true(audit_test_log_count() > 0, 'A failed ingestion query must emit an operational warning.'); +$audit_auth_fetch_fails = 'new'; +audit_poll_user_log(); +audit_test_assert_true(audit_test_log_count() > 1, 'A failed new-row query must emit an operational warning.'); +$audit_auth_fetch_fails = ''; + +$audit_auth_config['audit_user_log_batch_size'] = '0'; +$audit_auth_config['audit_retention'] = '0'; +audit_poll_user_log(); +$audit_auth_config['audit_user_log_batch_size'] = '5001'; +$audit_auth_config['audit_retention'] = '90'; +audit_poll_user_log(); +$audit_auth_config['audit_user_log_batch_size'] = '1000'; + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'claim-failure', 'user_id' => 60, 'result' => 1, 'ip' => '192.0.2.60', 'time' => '2026-07-25 16:00:00'] +]; +$audit_auth_fail_sql = ['INSERT IGNORE INTO audit_user_log_state']; +audit_poll_user_log(); +audit_test_assert_same([], $audit_auth_recorded_events, 'A failed source-row claim must skip event creation.'); +audit_test_assert_true(audit_test_log_count() > 0, 'A failed source-row claim must emit an operational warning.'); +audit_test_assert_same('0', read_config_option('audit_user_log_watermark_epoch'), 'A failed source-row claim must not advance the watermark.'); + +audit_test_reset_state(); +$finalize_epoch = (int) strtotime('2026-07-25 16:01:00 UTC'); +$audit_auth_user_log_rows = [ + ['username' => 'claim-finalize', 'user_id' => 61, 'result' => 1, 'ip' => '192.0.2.61', 'time' => '2026-07-25 16:01:00', 'source_epoch' => $finalize_epoch] +]; +$audit_auth_state_rows[audit_test_source_key('claim-finalize', 61, $finalize_epoch)] = [ + 'source_username' => 'claim-finalize', 'source_user_id' => 61, 'source_epoch' => $finalize_epoch, + 'source_time' => $finalize_epoch, 'audit_id' => 0, 'retry_count' => 4, + 'processed_time' => '2026-07-25 00:00:00' +]; +$audit_auth_retry_claims_ready = true; +$audit_auth_fail_sql = ['SET audit_id = ?']; +audit_poll_user_log(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'A failed finalization at the retry limit must retain only dropped-row evidence.'); +$finalize_dropped_params = audit_test_last_event_params(); +audit_test_assert_same('audit.authentication.ingestion.dropped', $finalize_dropped_params[12] ?? null, 'Finalization exhaustion must create a queryable dropped event.'); +audit_test_assert_same(1, count($audit_auth_state_rows), 'A failed claim finalization must retain the retryable marker.'); + +audit_test_reset_state(); +$audit_auth_config['audit_brute_force_window_minutes'] = '0'; +$audit_auth_config['audit_brute_force_threshold'] = '0'; +audit_detect_failed_login_volume(); +$audit_auth_config['audit_brute_force_window_minutes'] = '1441'; +$audit_auth_config['audit_brute_force_threshold'] = '1001'; +$audit_auth_failed_metrics = ['failed_attempts' => 999, 'distinct_usernames' => 1, 'distinct_ips' => 1]; +audit_detect_failed_login_volume(); + +$audit_auth_config['audit_brute_force_window_minutes'] = '5'; +$audit_auth_config['audit_brute_force_threshold'] = '10'; +$audit_auth_failed_metrics = ['failed_attempts' => 10, 'distinct_usernames' => 1, 'distinct_ips' => 1]; +$audit_auth_fail_sql = ['INSERT IGNORE INTO settings']; +audit_detect_failed_login_volume(); +audit_test_assert_same([], $audit_auth_recorded_events, 'A failed throttle-row initialization must suppress the anomaly event.'); + +audit_test_reset_state(); +$_SERVER['HTTP_REFERER'] = 'https://cacti.example.com:8443/private?token=secret'; +audit_custom_denied('MODE'); +$port_params = audit_test_last_event_params(); +$port_details = json_decode((string) ($port_params[24] ?? ''), true); +audit_test_assert_same('https://cacti.example.com:8443', is_array($port_details) ? ($port_details['referer_origin'] ?? null) : null, 'A non-default referer port must be preserved without retaining its path.'); print "Auth audit tests passed.\n"; diff --git a/tests/auth_sql_integration_test.php b/tests/auth_sql_integration_test.php new file mode 100644 index 0000000..f702a55 --- /dev/null +++ b/tests/auth_sql_integration_test.php @@ -0,0 +1,43 @@ +#!/usr/bin/env php +.*?)\n\}/s', $setup, $install_match) !== 1 || + str_contains($install_match['body'], 'audit_setup_user_log_indexes()')) { + fwrite(STDERR, 'Fresh installation must not index core user_log while authentication auditing is disabled.' . PHP_EOL); + exit(1); +} + +if (preg_match('/function audit_check_upgrade\(\): void \{(?.*?)\n\}/s', $setup, $upgrade_match) !== 1) { + fwrite(STDERR, 'Unable to inspect the plugin upgrade path.' . PHP_EOL); + exit(1); +} + +foreach (['audit_setup_user_log_state_table()', 'audit_persist_auth_defaults()', "'logout_post_session_destroy'", "'custom_denied'"] as $upgrade_requirement) { + if (!str_contains($upgrade_match['body'], $upgrade_requirement)) { + fwrite(STDERR, 'Missing 1.6 upgrade requirement: ' . $upgrade_requirement . PHP_EOL); + exit(1); + } +} + +if (preg_match('/function audit_config_settings\(\): void \{(?.*?)\n\}/s', $setup, $settings_match) !== 1 || + strpos($settings_match['body'], "'audit_enabled'") < strpos($settings_match['body'], 'audit_user_is_admin()')) { + fwrite(STDERR, 'Master and external audit controls must only be exposed to Audit Log Admin.' . PHP_EOL); + exit(1); +} $required_controller_guards = [ "\$_SERVER['REQUEST_METHOD'] !== 'POST'", @@ -17,9 +48,9 @@ "case 'syslog_retry':", 'audit_syslog_test_delivery()', 'audit_syslog_retry_dead_letters($delivery_ids)', - 'if (!is_array($data) || $data === [])', - "if (!audit_syslog_enabled()) {\n\t\treturn;", - "if (audit_syslog_enabled() && db_table_exists('audit_syslog_delivery'))", + 'if ($data === false || cacti_sizeof($data) === 0)', + "if (db_table_exists('audit_syslog_delivery'))", + "!\$enabled ? __('Disabled', 'audit')", 'cacti_sizeof($syslog) > 0', '$syslog[\'state\'] ?? \'unknown\'', '$syslog[\'attempts\'] ?? 0', @@ -39,7 +70,7 @@ "'audit_manage.php' => __('Audit Log Admin'", 'audit_setup_realms(true)', 'audit_setup_realms()', - 'audit_remove_deprecated_realms()', + 'audit_remove_obsolete_realms()', "auth_augment_roles(__('Audit Plugin', 'audit'), ['audit.php', 'audit_manage.php'])", 'api_plugin_register_hook(\'audit\', \'replicate_out\'', 'request_status', @@ -52,17 +83,24 @@ 'logout_post_session_destroy', 'custom_denied', 'audit_poll_user_log()', - 'audit_detect_brute_force()', + 'audit_detect_failed_login_volume()', 'audit_auth_log_enabled', 'audit_brute_force_enabled', 'audit_user_log_batch_size', 'array_merge($temp, $auth_settings, $syslog)', "'audit_user_log_batch_size' => '1000'", 'audit_persist_auth_defaults', + 'audit_setup_user_log_indexes', + 'audit_remove_user_log_indexes', + "'plugin_audit_time'", + "'plugin_audit_result_time'", 'CREATE TABLE IF NOT EXISTS `audit_user_log_state`', + 'KEY `pending_retry` (`audit_id`, `retry_count`, `processed_time`)', 'DROP TABLE IF EXISTS audit_user_log_state', - "'DELETE FROM settings WHERE LEFT(name, 6) = ?'", - "['audit_']", + 'function audit_owned_setting_names(): array', + "'DELETE FROM settings WHERE name IN ('", + 'audit_owned_setting_names()', + "array_diff(\$setting_names, ['audit_user_log_indexes_owned'])", 'event_uuid char(36)', 'operation_outcome', 'external_attempts', @@ -87,7 +125,7 @@ $required_auth_fragments = [ 'function audit_poll_user_log', - 'function audit_detect_brute_force', + 'function audit_detect_failed_login_volume', 'function audit_custom_denied', 'function audit_logout_post_session_destroy', 'function audit_user_log_event_descriptor', @@ -97,16 +135,28 @@ "'cacti.auth.password.changed'", "'cacti.auth.password_change_or_2fa_failed'", "'cacti.auth.login.unknown'", - "'cacti.auth.brute_force_suspected'", + "'cacti.auth.failed_login_volume_anomaly'", + "'authentication_environment'", + "'distinct_usernames'", + "'distinct_ips'", "'cacti.auth.authorization.denied'", "'authentication.logout.completed'", "'audit.configuration.denied'", - "'START TRANSACTION'", - "'ROLLBACK'", - "'COMMIT'", + 'UNIX_TIMESTAMP(ul.time) AS source_epoch', + 'source_username, source_user_id, source_epoch', + 'INNER JOIN user_log AS ul', + 'audit_auth_log_last_state', + 'retry_count = retry_count + ?', + 'VALUES (?, ?, ?, UTC_TIMESTAMP(), 0, 0, UTC_TIMESTAMP(6))', + "LIMIT ' .", + 'UPDATE audit_user_log_state', + 'audit_user_log_watermark_epoch', + 'function audit_user_log_event_uuid', + 'SELECT id FROM audit_log WHERE event_uuid = ?', + 'AND auls.retry_count < ?', "'defer_delivery'", - 'FROM audit_user_log_state AS auls', - 'INSERT IGNORE INTO settings (name, value)' + 'LEFT JOIN audit_user_log_state AS auls', + 'ON DUPLICATE KEY UPDATE value = GREATEST' ]; foreach ($required_auth_fragments as $fragment) { diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php index f5bcee4..ba45194 100644 --- a/tests/security_functions_test.php +++ b/tests/security_functions_test.php @@ -335,6 +335,14 @@ function audit_test_assert_same($expected, $actual, $message) { audit_test_assert_same('', file_get_contents($temporary_log), 'Empty audit events must not create external records.'); audit_test_assert_same([], $audit_test_external_updates, 'Empty audit events must not update delivery status.'); +$audit_test_external_event = [ + 'request_status' => 'completed', + 'external_status' => 'delivered' +]; +audit_deliver_external_event(999); +audit_test_assert_same('', file_get_contents($temporary_log), 'A delivered event must not be appended to the external file again.'); +audit_test_assert_same([], $audit_test_external_updates, 'A delivered event must not update delivery status again.'); + unlink($temporary_log); print "Security helper tests passed.\n"; diff --git a/tests/setup_defaults_test.php b/tests/setup_defaults_test.php new file mode 100644 index 0000000..db55506 --- /dev/null +++ b/tests/setup_defaults_test.php @@ -0,0 +1,71 @@ + 'on' +]; + +function db_fetch_cell_prepared(string $sql, array $params = []): int { + global $audit_default_settings; + + return array_key_exists((string) ($params[0] ?? ''), $audit_default_settings) ? 1 : 0; +} + +function set_config_option(string $name, string $value): void { + global $audit_default_settings; + + $audit_default_settings[$name] = $value; +} + +function audit_default_setting(string $name): string { + global $audit_default_settings; + + return (string) ($audit_default_settings[$name] ?? ''); +} + +require_once dirname(__DIR__) . '/setup.php'; + +function audit_default_assert_same(mixed $expected, mixed $actual, string $message): void { + if ($expected !== $actual) { + fwrite(STDERR, $message . PHP_EOL); + fwrite(STDERR, 'Expected: ' . var_export($expected, true) . PHP_EOL); + fwrite(STDERR, 'Actual: ' . var_export($actual, true) . PHP_EOL); + exit(1); + } +} + +$before = time(); +audit_persist_auth_defaults(); +$after = time(); + +audit_default_assert_same('on', audit_default_setting('audit_auth_log_enabled'), 'An existing administrator choice must not be overwritten.'); +audit_default_assert_same('off', audit_default_setting('audit_brute_force_enabled'), 'Failed-login volume detection must be opt-in.'); + +$watermark = (int) audit_default_setting('audit_user_log_watermark_epoch'); + +if ($watermark < $before || $watermark > $after) { + fwrite(STDERR, 'A new install or upgrade must start authentication ingestion at the current time.' . PHP_EOL); + exit(1); +} + +$seeded = set_config_option('audit_user_log_watermark_epoch', '12345'); +audit_persist_auth_defaults(); +audit_default_assert_same('12345', audit_default_setting('audit_user_log_watermark_epoch'), 'A persisted ingestion watermark must survive upgrades.'); + +$owned_settings = audit_owned_setting_names(); +audit_default_assert_same(count($owned_settings), count(array_unique($owned_settings)), 'Owned setting names must be unique.'); + +foreach (['audit_enabled', 'audit_auth_log_enabled', 'audit_syslog_health_state'] as $owned_setting) { + if (!in_array($owned_setting, $owned_settings, true)) { + fwrite(STDERR, 'Missing plugin-owned setting: ' . $owned_setting . PHP_EOL); + exit(1); + } +} + +if (in_array('audit_unrelated_extension_setting', $owned_settings, true)) { + fwrite(STDERR, 'Uninstall must not claim settings owned by another extension.' . PHP_EOL); + exit(1); +} + +print "Setup default tests passed.\n"; diff --git a/tests/setup_index_test.php b/tests/setup_index_test.php new file mode 100644 index 0000000..d820c75 --- /dev/null +++ b/tests/setup_index_test.php @@ -0,0 +1,182 @@ + 'username'], + ['COLUMN_NAME' => 'user_id'], + ['COLUMN_NAME' => 'time'] + ]; +} + +function cacti_log(string $message, bool $also_print = false, string $log_type = '', int $level = 0): void { + global $audit_index_logs; + $audit_index_logs[] = $message; +} + +require_once dirname(__DIR__) . '/setup.php'; + +function audit_index_assert_same(mixed $expected, mixed $actual, string $message): void { + if ($expected !== $actual) { + fwrite(STDERR, $message . PHP_EOL); + fwrite(STDERR, 'Expected: ' . var_export($expected, true) . PHP_EOL); + fwrite(STDERR, 'Actual: ' . var_export($actual, true) . PHP_EOL); + exit(1); + } +} + +/** + * @return array|null + */ +function audit_index_columns(string $index): ?array { + global $audit_indexes; + + return isset($audit_indexes[$index]) ? $audit_indexes[$index] : null; +} + +function audit_index_log_contains(string $needle): bool { + global $audit_index_logs; + + foreach ($audit_index_logs as $message) { + if (str_contains($message, $needle)) { + return true; + } + } + + return false; +} + +audit_setup_user_log_indexes(); +audit_index_assert_same(['time', 'username', 'user_id'], audit_index_columns('plugin_audit_time'), 'The ingestion index must lead with time.'); +audit_index_assert_same(['result', 'time'], audit_index_columns('plugin_audit_result_time'), 'The failed-login index must cover result and time.'); +audit_index_assert_same(2, count($audit_index_operations), 'Both indexes must be added exactly once.'); +audit_index_assert_same(true, audit_user_log_indexes_available(), 'Both confirmed local indexes must report available.'); +audit_index_assert_same(true, audit_user_log_identity_supported('identity-connection'), 'The real prepared-query signature must accept the identity query.'); +audit_index_assert_same('identity-connection', $audit_identity_connection, 'The identity query must pass the database connection in the fourth argument.'); + +audit_setup_user_log_indexes(); +audit_index_assert_same(2, count($audit_index_operations), 'Existing indexes must not be rebuilt.'); + +audit_remove_user_log_indexes(); +audit_index_assert_same([], $audit_indexes, 'Plugin-owned indexes must be removed during uninstall.'); +audit_index_assert_same(4, count($audit_index_operations), 'Both indexes must be removed exactly once.'); +audit_index_assert_same('', read_config_option('audit_user_log_indexes_owned'), 'Disabling the feature must clear stale index ownership.'); + +$audit_indexes = [ + 'plugin_audit_time' => ['time', 'username', 'user_id'] +]; +$audit_index_settings = [ + 'audit_user_log_indexes_owned' => 'plugin_audit_time' +]; +$audit_index_operations = []; +audit_setup_user_log_indexes(); +audit_index_assert_same('plugin_audit_time,plugin_audit_result_time', $audit_index_settings['audit_user_log_indexes_owned'], 'A repair run must preserve prior ownership while recording a recreated index.'); +audit_remove_user_log_indexes(); +audit_index_assert_same([], $audit_indexes, 'Uninstall must remove every index accumulated in the ownership record.'); + +$audit_indexes = ['plugin_audit_time' => ['time', 'username', 'user_id']]; +$audit_index_settings = []; +$audit_index_operations = []; +audit_setup_user_log_indexes(); +audit_index_assert_same('plugin_audit_result_time', read_config_option('audit_user_log_indexes_owned'), 'A pre-existing index with the same name must not become plugin-owned.'); +audit_remove_user_log_indexes(); +audit_index_assert_same(['plugin_audit_time' => ['time', 'username', 'user_id']], $audit_indexes, 'An unowned pre-existing index must never be removed.'); + +$audit_indexes = [ + 'plugin_audit_time' => ['time', 'username', 'user_id'], + 'hostile_setting' => ['unexpected'] +]; +$audit_index_settings = [ + 'audit_user_log_indexes_owned' => 'plugin_audit_time,hostile_setting' +]; +$audit_index_operations = []; +audit_remove_user_log_indexes(); +audit_index_assert_same(['hostile_setting' => ['unexpected']], $audit_indexes, 'Settings data must not authorize arbitrary index names in DDL.'); + +$audit_indexes = [ + 'plugin_audit_time' => ['time', 'username', 'user_id'], + 'plugin_audit_result_time' => ['result', 'time'] +]; +$audit_index_settings = [ + 'audit_user_log_indexes_owned' => 'plugin_audit_time,plugin_audit_result_time' +]; +$audit_index_drop_failures = ['plugin_audit_time']; +$audit_index_logs = []; +audit_index_assert_same(false, audit_remove_user_log_indexes(), 'A failed core-table index removal must fail closed.'); +audit_index_assert_same(['plugin_audit_time' => ['time', 'username', 'user_id']], $audit_indexes, 'A failed index removal must leave the index intact.'); +audit_index_assert_same('plugin_audit_time', read_config_option('audit_user_log_indexes_owned'), 'Failed index ownership must remain journaled.'); +audit_index_assert_same(true, audit_index_log_contains('plugin_audit_time'), 'The removal failure must name the orphaned index in the operator log.'); +$audit_index_drop_failures = []; + +$audit_index_operations = []; +audit_setup_user_log_indexes('remote'); +audit_remove_user_log_indexes('remote'); +audit_index_assert_same([], $audit_index_operations, 'Remote collector indexes must not be created or removed.'); +audit_index_assert_same(false, audit_user_log_indexes_available('remote'), 'Remote collectors must never report local ingestion indexes available.'); + +$audit_index_table_exists = false; +audit_setup_user_log_indexes(); +audit_remove_user_log_indexes(); +audit_index_assert_same([], $audit_index_operations, 'Missing user_log must be a no-op.'); + +print "Setup index tests passed.\n"; From e1b03a94bb8b0985970e2f065e1f1580b6065484 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 30 Aug 2026 18:06:50 -0700 Subject: [PATCH 8/9] ci: test PHP 8.2-8.4 against Cacti and add ondrej PPA for apache mod Cacti core now requires PHP >= 8.2, so the 8.1 integration job fails the composer platform check; Ubuntu Noble also lacks libapache2-mod-php for non-native versions without the ondrej PPA. Signed-off-by: Thomas Vincent --- .github/workflows/plugin-ci-workflow.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 1ed7180..7532515 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -84,7 +84,9 @@ jobs: run: php -v - name: Run apt-get update - run: sudo apt-get update + run: | + sudo add-apt-repository -y ppa:ondrej/php + sudo apt-get update - name: Install System Dependencies run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping From aef11a466a434531ccf06007a44b34fb901d11d9 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sun, 30 Aug 2026 18:32:37 -0700 Subject: [PATCH 9/9] ci: drop EOL PHP 8.1 from the code quality matrix Cacti and the plugin require PHP >= 8.2. Signed-off-by: Thomas Vincent --- .github/workflows/code-quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index da96174..d05674b 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4'] name: PHP ${{ matrix.php }} Code Quality