diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 0e9994a..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 @@ -96,5 +96,9 @@ 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 + 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 befe032..7532515 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 @@ -83,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 @@ -108,15 +111,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: | @@ -195,14 +198,44 @@ 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: | 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: | @@ -211,6 +244,10 @@ 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 + 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: | @@ -241,6 +278,109 @@ 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 " + 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 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(*) + 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_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 + ); + ") + + 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 + + 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 }} @@ -256,3 +396,30 @@ 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'); + ") + 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 = 'user_log' + AND index_name IN ('plugin_audit_time', 'plugin_audit_result_time'); + ") + + 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 d56164f..61d5469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +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 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 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: 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 cbffe64..64e6eb6 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,78 @@ 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. +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 +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 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. ## Permissions diff --git a/audit.php b/audit.php index dcbf213..f023633 100644 --- a/audit.php +++ b/audit.php @@ -128,7 +128,7 @@ WHERE id = ?', [get_filter_request_var('id')]); - if (!is_array($data)) { + if ($data === false || cacti_sizeof($data) === 0) { http_response_code(404); print html_escape(__('Audit event not found.', 'audit')); @@ -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 (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) . ''; } } } 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 + $selected_items */ @@ -428,6 +434,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,13 +449,15 @@ 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; } $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); - if (!is_array($event) || $event === [] || ($event['request_status'] ?? '') === 'started') { + if (!is_array($event) || $event === [] || + ($event['request_status'] ?? '') === 'started' || + ($event['external_status'] ?? '') === 'delivered') { return; } @@ -464,7 +476,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 +636,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,11 +681,11 @@ 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; } - $event_uuid = audit_uuid_v4(); + $event_uuid = $options['event_uuid'] ?? audit_uuid_v4(); $correlation_id = $options['correlation_id'] ?? audit_request_correlation_id(); $user_id = $options['user_id'] ?? ($_SESSION['sess_user_id'] ?? 0); $page = $options['page'] ?? basename($_SERVER['SCRIPT_NAME'] ?? 'cli'); @@ -681,7 +697,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,30 +716,771 @@ 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') + ]); + $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] ]); } +/** + * 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 { + return match (true) { + $result === 0 => [ + 'event_type' => 'cacti.auth.login.failed', + 'severity' => 'warning', + 'outcome' => 'failure', + 'action' => 'login_failed', + 'details' => [] + ], + $result === 1 => [ + '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') + ] + ], + $result === 2 => [ + 'event_type' => 'cacti.auth.login.token', + 'severity' => 'info', + 'outcome' => 'success', + 'action' => 'login_token', + 'details' => [] + ], + $result === 3 && $user_id > 0 => [ + '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') + ] + ], + $result === 3 => [ + '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') + ] + ], + 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; + } + + 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 + ]); +} + +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]); +} + +/** + * 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, 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 stale retry markers followed by new + * rows above the high-water floor. Pending markers never lower that floor. + */ +function audit_poll_user_log(): void { + $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'); + } + + 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; + } + + $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; + } + + $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 ' . (int) $pending_limit, + [$max_retries] + ); + + 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; + } + + $retry_failures = 0; + $retry_exhausted = 0; + + foreach ($rows as $row) { + $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]); + } + + if (!$claimed) { + audit_log_ingestion_warning('Authentication audit source-row claim failed; ingestion cycle stopped'); + + 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; + + 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) { + $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; + } + + $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 (!$finalized || db_affected_rows() !== 1) { + if ($created) { + db_execute_prepared('DELETE FROM audit_log WHERE id = ?', [$audit_id]); + } + + $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 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_failed_login_volume(): 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; + } + + $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; + } + + // 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.failed_login_volume_anomaly', [ + 'event_category' => 'authentication', + 'action' => 'failed_login_volume_anomaly', + 'severity' => 'critical', + 'operation_outcome' => 'failure', + 'actor_type' => 'system', + 'target_type' => 'authentication_environment', + 'target_id' => 'global', + 'details' => [ + 'scope' => 'global', + 'failed_attempts' => $count, + 'distinct_usernames' => (int) ($metrics['distinct_usernames'] ?? 0), + 'distinct_ips' => (int) ($metrics['distinct_ips'] ?? 0), + '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. Paths and query strings can both contain + // tokens, reset hashes, OAuth state, or session identifiers. + $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']; + } + } + } + + $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; +} + +/** + * @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'] ?? ''; @@ -739,30 +1496,56 @@ function audit_enforce_syslog_settings_request(): void { return; } - $has_syslog_fields = false; + $groups = audit_settings_field_groups($post); + $has_syslog_fields = $groups['syslog']; + $has_auth_fields = $groups['auth']; - foreach ($post as $name => $value) { - if (strpos((string) $name, 'audit_syslog_') === 0) { - $has_syslog_fields = true; + if (!$has_syslog_fields && !$has_auth_fields) { + return; + } - break; + if (!audit_user_is_admin()) { + // 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' + ]); } - } - if (!$has_syslog_fields) { - return; + 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; } - 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' - ]); - http_response_code(403); + $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 10ff4f0..398c83d 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() || + !audit_log_table_available() || + !db_table_exists('audit_syslog_delivery')) { return; } @@ -808,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-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/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 41dadc8..34d81cf 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,40 @@ 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 + * 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' => '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_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) { + $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 { @@ -74,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 = ? @@ -104,19 +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(); - return true; + if (!$indexes_removed) { + $setting_names = array_values(array_diff($setting_names, ['audit_user_log_indexes_owned'])); + } + + db_execute_prepared( + 'DELETE FROM settings WHERE name IN (' . implode(', ', array_fill(0, count($setting_names), '?')) . ')', + $setting_names + ); + + 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 { @@ -170,8 +260,10 @@ 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(); + audit_remove_obsolete_realms(); db_execute_prepared('UPDATE plugin_config SET version = ? @@ -190,6 +282,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,19 +336,38 @@ 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 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); } return $data; } 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(); - $last_check = read_config_option('audit_last_check'); - $now = gmdate('Y-m-d'); + // 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(); - if ($last_check != $now) { + // Detect aggregate failed-login volume after importing the current batch. + audit_detect_failed_login_volume(); + + if ($is_daily) { $retention = read_config_option('audit_retention'); if ($retention > 0) { @@ -329,10 +442,176 @@ 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. + * + * 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_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, + $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 + ); + } +} + +/** + * 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); + } + } + + $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 { db_execute("CREATE TABLE IF NOT EXISTS `audit_syslog_delivery` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, @@ -511,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', @@ -552,9 +834,51 @@ function audit_config_settings(): void { 'default' => '/var/www/html/cacti/log/audit.log', 'max_length' => '255' ], - ]; + ]; + + $auth_settings = [ + 'audit_auth_header' => [ + 'friendly_name' => __('Authentication Auditing', 'audit'), + 'method' => 'spacer', + ], + 'audit_auth_log_enabled' => [ + 'friendly_name' => __('Enable Authentication Auditing', 'audit'), + 'description' => __('Opt in to capture new login, logout, token, password-change, and authorization-denied events from this point forward.', 'audit'), + 'method' => 'checkbox', + 'default' => 'off' + ], + 'audit_brute_force_enabled' => [ + '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' => 'off' + ], + 'audit_brute_force_window_minutes' => [ + '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', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_brute_force_threshold' => [ + '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', + 'size' => '8' + ], + 'audit_user_log_batch_size' => [ + 'friendly_name' => __('User Log Ingestion Batch Size', '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', + 'size' => '8' + ], + ]; - if (php_sapi_name() === 'cli' || audit_user_is_admin()) { $facility_options = []; foreach (audit_syslog_facilities() as $facility => $code) { @@ -728,7 +1052,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_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 new file mode 100644 index 0000000..a243d2c --- /dev/null +++ b/tests/auth_audit_test.php @@ -0,0 +1,1164 @@ + '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_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; + + 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 audit_setup_user_log_indexes(): bool { + global $audit_auth_index_actions, $audit_auth_index_setup_ok; + $audit_auth_index_actions[] = 'setup'; + + return $audit_auth_index_setup_ok; +} + +function audit_user_log_indexes_available(): bool { + global $audit_auth_index_actions, $audit_auth_index_setup_ok; + $audit_auth_index_actions[] = 'check'; + + return $audit_auth_index_setup_ok; +} + +function audit_user_log_identity_supported(): bool { + global $audit_auth_identity_ok; + + return $audit_auth_identity_ok; +} + +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; + + $audit_auth_executed_sql[] = $sql; + + foreach ($audit_auth_fail_sql as $fragment) { + if (str_contains($sql, $fragment)) { + return false; + } + } + + 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[] = ['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) { + $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[$key])) { + $audit_auth_affected_rows = 0; + } else { + $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; + } + + 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]; + + 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) { + $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; + } + + return true; +} + +function db_fetch_insert_id(): int { + 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_failed_metrics; + + if (str_contains($sql, 'COUNT(*) AS failed_attempts')) { + return $audit_auth_failed_metrics; + } + + 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|false { + global $audit_auth_user_log_rows, $audit_auth_state_rows, $audit_auth_fetches, $audit_auth_retry_claims_ready, $audit_auth_fetch_fails; + + if (!str_contains($sql, 'user_log AS ul')) { + return []; + } + + $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) { + $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['source_epoch'] !== $b['source_epoch']) { + return $a['source_epoch'] <=> $b['source_epoch']; + } + + if ($a['username'] !== $b['username']) { + return $a['username'] <=> $b['username']; + } + + return $a['user_id'] <=> $b['user_id']; + }); + + return array_slice($filtered, 0, $limit); +} + +function db_fetch_cell_prepared(string $sql, array $params = []): int|string { + global $audit_auth_recorded_events, $audit_auth_database_epoch, $audit_auth_state_rows; + + if (str_contains($sql, 'SELECT UNIX_TIMESTAMP()')) { + return $audit_auth_database_epoch ?? time(); + } + + 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']; + } + } + } + + 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 ''; +} + +function db_affected_rows(): int { + global $audit_auth_affected_rows; + + return $audit_auth_affected_rows; +} + +function db_table_exists(string $table): bool { + 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; + } + + 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 { + 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 { + 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); + } +} + +/** + * @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_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'; +} + +// --------------------------------------------------------------------------- +// 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.'); + +$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 +// --------------------------------------------------------------------------- + +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_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 +// --------------------------------------------------------------------------- + +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. +$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 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'] +]; +$audit_auth_state_conflict = true; + +audit_poll_user_log(); +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.'); + +// --------------------------------------------------------------------------- +// 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 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(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_retry_claims_ready = true; +audit_poll_user_log(); +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 +// --------------------------------------------------------------------------- + +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((string) ($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. 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_metrics = ['failed_attempts' => 9, 'distinct_usernames' => 9, 'distinct_ips' => 9]; +$audit_auth_recorded_events = []; +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_metrics = ['failed_attempts' => 10, 'distinct_usernames' => 7, 'distinct_ips' => 4]; +$audit_auth_recorded_events = []; +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.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_metrics = ['failed_attempts' => 12, 'distinct_usernames' => 8, 'distinct_ips' => 5]; +$audit_auth_recorded_events = []; +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_metrics = ['failed_attempts' => 12, 'distinct_usernames' => 8, 'distinct_ips' => 5]; +$audit_auth_recorded_events = []; +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_metrics = ['failed_attempts' => 10, 'distinct_usernames' => 2, 'distinct_ips' => 1]; +$audit_auth_insert_fails = true; +$audit_auth_recorded_events = []; +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_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_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'; + +// --------------------------------------------------------------------------- +// 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/reset/secret-path?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', $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.'); + +// 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.'); +$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.'); + +// 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(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'; + +// --------------------------------------------------------------------------- +// 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 +// (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'); + +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.' +); +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.' +); +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'", @@ -16,7 +47,15 @@ "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 ($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', + '$syslog[\'sent_time\'] ?? \'\'', + '$syslog[\'last_error\'] ?? \'\'' ]; foreach ($required_controller_guards as $guard) { @@ -31,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', @@ -41,6 +80,27 @@ 'audit_retry_external_logs()', 'audit_process_syslog_queue()', 'logout_pre_session_destroy', + 'logout_post_session_destroy', + 'custom_denied', + 'audit_poll_user_log()', + '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', + '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', @@ -63,6 +123,49 @@ "register_shutdown_function('audit_finalize_request', \$audit_id, \$started_at, \$verifier)" ]; +$required_auth_fragments = [ + 'function audit_poll_user_log', + 'function audit_detect_failed_login_volume', + '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.failed_login_volume_anomaly'", + "'authentication_environment'", + "'distinct_usernames'", + "'distinct_ips'", + "'cacti.auth.authorization.denied'", + "'authentication.logout.completed'", + "'audit.configuration.denied'", + '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'", + 'LEFT JOIN audit_user_log_state AS auls', + 'ON DUPLICATE KEY UPDATE value = GREATEST' +]; + +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); 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"; 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 = []) {