Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
197 changes: 182 additions & 15 deletions .github/workflows/plugin-ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
matrix:
php: ['8.4']
os: [ubuntu-latest]
cacti_branch: ['1.2.x', 'develop']

services:
mysql:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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: |
Expand Down Expand Up @@ -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: |
Expand All @@ -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: |
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion INFO
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

[info]
name = audit
version = 1.5
version = 1.6
longname = Audit Plugin for Cacti
author = The Cacti Group
email =
Expand Down
74 changes: 72 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading