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 = []) {