Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
eb77d25
code review sweep
bmfmancini Jul 24, 2026
1dc34b2
Update audit.php
bmfmancini Jul 24, 2026
98c88f7
additional sweep
bmfmancini Jul 24, 2026
4660680
rename some columns to be more specific
bmfmancini Jul 24, 2026
6d3afb2
Support json for external logging
bmfmancini Jul 24, 2026
ab887ac
fix json formatting
bmfmancini Jul 24, 2026
6e27cc3
expand auditing coverage
bmfmancini Jul 24, 2026
d3fb6c5
remove file
bmfmancini Jul 24, 2026
ff37c63
update php version
bmfmancini Jul 24, 2026
bb8b4da
fix failing workflow
bmfmancini Jul 24, 2026
0baa006
Merge upstream/develop into feature/compliance-event-capture
bmfmancini Jul 24, 2026
6b697af
Seperate puge log permissions
bmfmancini Jul 25, 2026
77ddc4f
rename permissions
bmfmancini Jul 25, 2026
aba1684
clean up name
bmfmancini Jul 25, 2026
3b7b330
group permissions for audit
bmfmancini Jul 25, 2026
0c78634
simply permissions
bmfmancini Jul 25, 2026
3f243db
Add more reliable way to capture success/fail
bmfmancini Jul 25, 2026
7c3689d
fix version
bmfmancini Jul 25, 2026
faeb08f
Merge remote-tracking branch 'upstream/develop' into feature/complian…
bmfmancini Jul 25, 2026
6ce4a08
Add secure remote Syslog audit delivery
bmfmancini Jul 25, 2026
0bee6bf
Fix Syslog warning leakage in poller
bmfmancini Jul 25, 2026
973adce
Delete openspec/changes/add-evidence-integrity-governance directory
bmfmancini Jul 25, 2026
bc08d10
Include audit evidence in CEF records
bmfmancini Jul 25, 2026
7afbf6d
Merge remote-tracking branch 'upstream/develop' into feature/evidence…
bmfmancini Jul 25, 2026
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
13 changes: 13 additions & 0 deletions .github/workflows/plugin-ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,17 @@ jobs:
echo "Audit schema security columns are missing"
exit 1
fi

SYSLOG_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_syslog_delivery';
")
if [ "$SYSLOG_TABLE_COUNT" -ne 1 ]; then
echo "Audit Syslog delivery queue table is missing"
exit 1
fi

- name: Check PHP Syntax for Plugin
run: |
Expand All @@ -198,6 +209,8 @@ jobs:
cd ${{ github.workspace }}/cacti/plugins/audit
php tests/security_functions_test.php
php tests/controller_security_test.php
php tests/syslog_functions_test.php
php tests/syslog_queue_test.php

- name: Run Cacti Poller
run: |
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

--- develop ---

* feature: Add standards-based remote Syslog delivery over UDP, TCP, and verified TLS
* feature: Add RFC 5424 headers with RFC 5424, CEF, or compact JSON message formats
* feature: Queue remote delivery in the poller with exponential backoff, dead-letter handling, health reporting, and audited admin actions
* security: Restrict Syslog settings, tests, and dead-letter retries to Audit Log Admin with validated destinations and CSRF-protected POST actions
* security: Preserve unfinished Syslog evidence during scheduled retention and manual purge
* issue: Capture expected Syslog connection and write warnings without leaking PHP notices into the Cacti poller log
* feature: Include redacted submitted, object, and detail data in CEF Syslog records
* feature: Verify user realm permission saves against the resulting database state
* security: Group Audit Log User and Audit Log Admin permissions under Audit Plugin
* feature: Add normalized compliance event identifiers, categories, actors, targets, outcomes, timing, and integrity metadata
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.4
version = 1.5
longname = Audit Plugin for Cacti
author = The Cacti Group
email =
Expand Down
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,63 @@ events. External records are written only after request finalization, so SIEM
consumers receive the final request status instead of the earlier transient
`started` state. Consumers should deduplicate on `event_uuid`.

## Remote Syslog

Version 1.5 adds standards-based Remote Syslog as the plugin's only remote
network transport. The plugin does not implement product-specific webhooks,
Splunk HEC, or Microsoft Sentinel APIs. Splunk, Sentinel, and other SIEM
products can ingest the existing audit file or receive Syslog through their
normal collector or forwarder.

Remote Syslog is configured under Configuration -> Settings -> Audit by a user
with the Audit Log Admin permission. It is independent of the existing
external-file option, so either destination or both destinations may be
enabled.

The available settings include:

* Receiver hostname or IP address and an optional explicit port.
* UDP, TCP, or TLS transport. A blank port uses 514 for UDP/TCP or 6514 for TLS.
* RFC 5424 structured data, CEF, or compact JSON message payload.
* Syslog facility, application name, stable Cacti node identity, and poller
identity.
* Bounded connection/write timeout, poller batch size, retry attempts, and
exponential-backoff delays.
* Maximum UDP record size and health-warning thresholds.
* Optional TLS CA bundle and mutual-TLS client certificate/key paths.

All records use an RFC 5424 header. TCP and TLS records use RFC 6587
octet-count framing. TLS always verifies the receiver certificate and hostname;
there is no setting to disable verification.

CEF records include the same redacted submitted request data, captured object
data, and event details available to JSON consumers in the `cs4`, `cs5`, and
`cs6` custom string fields. The corresponding labels are `Submitted Data`,
`Object Data`, and `Details`.

UDP sends one complete event per datagram. If a formatted event is larger than
the configured UDP maximum, it is moved to dead-letter instead of being
truncated or split. TCP or TLS is recommended when events can be large or when
transport reliability matters.

Remote network I/O does not run in the originating web request. Finalized events
are added to `audit_syslog_delivery` and sent in bounded batches by the Cacti
poller. Transient connection and write errors use bounded exponential backoff.
Permanent formatting/configuration errors and exhausted retries enter
dead-letter state. Audit Log Admin can test the stored configuration and reset
dead-letter events from the Audit Log page; both actions require CSRF-protected
POST requests and are audited.

A successful UDP, TCP, or TLS socket write is shown as `sent_unconfirmed`. This
means the local operating system accepted the complete record; standard Syslog
does not confirm that a receiver durably stored it. Every transmission retains
the same `event_uuid`, which receivers should use as their deduplication key.

The Audit Log page reports pending, retry, sent-unconfirmed, and dead-letter
counts, oldest pending age, last attempt, last socket write, and the latest
bounded error. Scheduled retention and manual purge preserve events with
pending, retry, or dead-letter Syslog delivery.

The normalized fields distinguish request processing from the result of the
requested Cacti operation. `request_status=completed` means that PHP request
processing completed without a fatal error or an HTTP error response. It does
Expand Down
174 changes: 167 additions & 7 deletions audit.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,77 @@
case 'export':
audit_export_rows();

break;
case 'syslog_test':
if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}

if (!audit_user_is_admin() || !csrf_check(false)) {
http_response_code(403);
exit;
}

$result = audit_syslog_test_delivery();
$success = $result['status'] === 'sent_unconfirmed';
audit_record_event('audit.syslog.test', array(
'event_category' => 'audit',
'severity' => $success ? 'notice' : 'warning',
'action' => 'test',
'target_type' => 'syslog_receiver',
'operation_outcome' => $success ? 'success' : 'failure',
'outcome_reason' => $success ? 'socket_write_completed' : ($result['error_code'] ?? 'delivery_failed'),
'details' => array(
'transport' => audit_syslog_config()['transport'],
'status' => $result['status'],
'error_code' => $result['error_code'] ?? ''
)
));
$_SESSION['audit_message'] = $success
? __('Syslog test record was written to the local socket. Receiver storage is unconfirmed.', 'audit')
: __('Syslog test failed: %s', html_escape($result['error'] ?? 'Unknown error'), 'audit');
raise_message('audit_message');

top_header();
audit_log();
bottom_footer();

break;
case 'syslog_retry':
if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}

if (!audit_user_is_admin() || !csrf_check(false)) {
http_response_code(403);
exit;
}

$post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW);
$post = is_array($post) ? $post : array();
$delivery_ids = isset($post['delivery_ids']) && is_array($post['delivery_ids'])
? $post['delivery_ids']
: array();
$retried = audit_syslog_retry_dead_letters($delivery_ids);
audit_record_event('audit.syslog.dead_letter.retried', array(
'event_category' => 'audit',
'severity' => 'notice',
'action' => 'retry',
'target_type' => 'syslog_delivery',
'operation_outcome' => 'success',
'details' => array('row_count' => $retried)
));
$_SESSION['audit_message'] = __('Reset %d dead-letter Syslog deliveries for retry.', $retried, 'audit');
raise_message('audit_message');

top_header();
audit_log();
bottom_footer();

break;
case 'purge':
if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
Expand Down Expand Up @@ -95,9 +166,29 @@ function audit_render_event_details($data) {
if ($data['outcome_reason'] != '') {
$output .= '<br><span><b>' . __('Outcome Reason:', 'audit') . '</b> <i>' . html_escape($data['outcome_reason']) . '</i></span>';
}
$output .= '<br><span><b>' . __('External Delivery:', 'audit') . '</b> <i>' . html_escape($data['external_status']) . '</i></span>';
$output .= '<br><span><b>' . __('External File Delivery:', 'audit') . '</b> <i>' . html_escape($data['external_status']) . '</i></span>';
if ($data['external_error'] != '') {
$output .= '<br><span><b>' . __('External Error:', 'audit') . '</b> <i>' . html_escape($data['external_error']) . '</i></span>';
$output .= '<br><span><b>' . __('External File Error:', 'audit') . '</b> <i>' . html_escape($data['external_error']) . '</i></span>';
}

if (db_table_exists('audit_syslog_delivery')) {
$syslog = db_fetch_row_prepared('SELECT state, attempts, last_attempt, sent_time, last_error
FROM audit_syslog_delivery
WHERE audit_id = ?
ORDER BY id DESC
LIMIT 1',
array($data['id']));

if (cacti_sizeof($syslog)) {
$output .= '<br><span><b>' . __('Remote Syslog Delivery:', 'audit') . '</b> <i>' . html_escape($syslog['state']) . '</i></span>';
$output .= '<br><span><b>' . __('Syslog Attempts:', 'audit') . '</b> <i>' . (int) $syslog['attempts'] . '</i></span>';
if ($syslog['sent_time'] != '') {
$output .= '<br><span><b>' . __('Syslog Socket Write:', 'audit') . '</b> <i>' . html_escape($syslog['sent_time']) . '</i></span>';
}
if ($syslog['last_error'] != '') {
$output .= '<br><span><b>' . __('Syslog Error:', 'audit') . '</b> <i>' . html_escape($syslog['last_error']) . '</i></span>';
}
}
}
$output .= '<hr>';

Expand Down Expand Up @@ -164,16 +255,37 @@ function audit_render_value($value) {
}

function audit_purge() {
db_execute('TRUNCATE TABLE audit_log');
$protected = db_fetch_cell("SELECT COUNT(*)
FROM audit_log
WHERE EXISTS (
SELECT 1
FROM audit_syslog_delivery
WHERE audit_syslog_delivery.audit_id = audit_log.id
AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter')
)");

db_execute("DELETE FROM audit_log
WHERE NOT EXISTS (
SELECT 1
FROM audit_syslog_delivery
WHERE audit_syslog_delivery.audit_id = audit_log.id
AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter')
)");
$purged = db_affected_rows();

audit_record_event('audit.log.purged', array(
'event_category' => 'audit',
'severity' => 'warning',
'action' => 'purge',
'target_type' => 'audit_log'
'target_type' => 'audit_log',
'details' => array(
'purged_count' => $purged,
'protected_count' => (int) $protected
)
));

$_SESSION['audit_message'] = __('Audit Log Purged by %s', get_username($_SESSION['sess_user_id']), 'audit');
$_SESSION['audit_message'] = __('Audit Log purge by %s removed %d events and protected %d events with unfinished Syslog delivery.',
get_username($_SESSION['sess_user_id']), $purged, (int) $protected, 'audit');

cacti_log('NOTE: Audit Log Purged by ' . get_username($_SESSION['sess_user_id']), false, 'WEBUI');

Expand Down Expand Up @@ -342,6 +454,10 @@ function audit_log() {
$rows = get_request_var('rows');
}

if (audit_user_is_admin()) {
audit_render_syslog_health();
}

html_start_box(__('Audit Log', 'audit'), '100%', '', '3', 'center', '');

?>
Expand Down Expand Up @@ -499,10 +615,10 @@ function audit_log() {
'tip' => __('Request processing state; completion does not guarantee that every operation succeeded.', 'audit')
),
'external_status' => array(
'display' => __('External Delivery', 'audit'),
'display' => __('External File Delivery', 'audit'),
'align' => 'left',
'sort' => 'ASC',
'tip' => __('Delivery state for the optional external audit log.', 'audit')
'tip' => __('Delivery state for the optional external audit log file. Remote Syslog health is shown separately.', 'audit')
),
'user_agent' => array(
'display' => __('User Agent', 'audit'),
Expand Down Expand Up @@ -567,3 +683,47 @@ function audit_log() {
<script type='text/javascript' src='plugins/audit/js/functions.js'></script>
<?php
}

function audit_render_syslog_health() {
$config = audit_syslog_config();
$health = audit_syslog_health();
$enabled = audit_syslog_enabled();
$unhealthy = $enabled && (
!$config['valid'] ||
$health['dead_letter'] >= $config['dead_letter_warning'] ||
$health['oldest_pending_seconds'] >= $config['pending_age_warning']
);

html_start_box(__('Remote Syslog Delivery', 'audit'), '100%', '', '3', 'center', '');

print "<tr class='" . ($unhealthy ? 'error' : 'even') . "'>";
print '<td><b>' . __('Status', 'audit') . '</b></td>';
print '<td>' . html_escape(!$enabled ? __('Disabled', 'audit') : ($unhealthy ? __('Unhealthy', 'audit') : __('Healthy', 'audit'))) . '</td>';
print '<td><b>' . __('Pending', 'audit') . '</b></td><td>' . (int) $health['pending'] . '</td>';
print '<td><b>' . __('Retry', 'audit') . '</b></td><td>' . (int) $health['retry'] . '</td>';
print '<td><b>' . __('Dead-letter', 'audit') . '</b></td><td>' . (int) $health['dead_letter'] . '</td>';
print '<td><b>' . __('Sent (Unconfirmed)', 'audit') . '</b></td><td>' . (int) $health['sent_unconfirmed'] . '</td>';
print '</tr>';

print "<tr class='odd'>";
print '<td><b>' . __('Oldest Pending', 'audit') . '</b></td><td>' . (int) $health['oldest_pending_seconds'] . ' ' . html_escape(__('seconds', 'audit')) . '</td>';
print '<td><b>' . __('Last Attempt', 'audit') . '</b></td><td>' . html_escape($health['last_attempt'] ?? __('Never', 'audit')) . '</td>';
print '<td><b>' . __('Last Socket Write', 'audit') . '</b></td><td>' . html_escape($health['last_sent'] ?? __('Never', 'audit')) . '</td>';
print '<td colspan="4">';
print "<button type='button' id='syslog_test' class='ui-button ui-corner-all ui-widget'>" . __esc('Test Syslog', 'audit') . '</button> ';
if ($health['dead_letter'] > 0) {
print "<button type='button' id='syslog_retry' class='ui-button ui-corner-all ui-widget' data-confirm='" .
__esc('Retry all dead-letter Syslog events?', 'audit') . "'>" . __esc('Retry Dead-letter', 'audit') . '</button>';
}
print '</td></tr>';

if ($enabled && !$config['valid']) {
print "<tr class='error'><td colspan='10'><b>" . __esc('Configuration Error:', 'audit') . '</b> ' .
html_escape(implode(', ', $config['errors'])) . '</td></tr>';
} elseif ($health['last_error'] !== null) {
print "<tr class='error'><td colspan='10'><b>" . __esc('Last Error:', 'audit') . '</b> ' .
html_escape($health['last_error']) . '</td></tr>';
}

html_end_box();
}
Loading