Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .github/workflows/plugin-ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,8 @@ jobs:
exit 1
fi

CLI_STATUS=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se "select request_status from audit_log where action = 'cli' order by id desc limit 1;")
if [ "$CLI_STATUS" != "completed" ]; then
echo "Unexpected CLI request status: $CLI_STATUS"
exit 1
fi
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

--- develop ---

* 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
* feature: Deliver finalized request outcomes to external log consumers
* feature: Audit audit-log views, searches, event detail access, exports, and purges
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,22 @@ not by itself prove that page-specific validation or database work succeeded.
`operation_outcome` remains `unknown` unless an authoritative Cacti 1.2.x hook
or plugin-owned operation supplies the result.

For direct user realm permission saves, the plugin verifies the resulting
`user_auth_realm` rows against the submitted realm set after Cacti processes the
request. Matching state is recorded as `success` with outcome reason
`realm_permissions_verified`; a mismatch is recorded as `failure`.

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.

## Permissions

The plugin groups its Cacti realms under the Audit Plugin permissions section.
Audit Log User permits access to the audit log. Audit Log Admin permits plugin
administration and purging audit events.

## Possible Bugs

If you figure out this problem, see the Cacti forums!
Expand Down
7 changes: 5 additions & 2 deletions audit.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
exit;
}

if (!api_plugin_user_realm_auth('audit_manage.php') || !csrf_check(false)) {
if (!audit_user_is_admin() || !csrf_check(false)) {
http_response_code(403);
exit;
}
Expand Down Expand Up @@ -92,6 +92,9 @@ function audit_render_event_details($data) {
$output .= '<br><span><b>' . __('Event ID:', 'audit') . '</b> <i>' . html_escape($data['event_uuid']) . '</i></span>';
$output .= '<br><span><b>' . __('Request Status:', 'audit') . '</b> <i>' . html_escape($data['request_status']) . '</i></span>';
$output .= '<br><span><b>' . __('Operation Outcome:', 'audit') . '</b> <i>' . html_escape($data['operation_outcome']) . '</i></span>';
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>';
if ($data['external_error'] != '') {
$output .= '<br><span><b>' . __('External Error:', 'audit') . '</b> <i>' . html_escape($data['external_error']) . '</i></span>';
Expand Down Expand Up @@ -407,7 +410,7 @@ function audit_log() {
<button type='submit' id='refresh' class='ui-button ui-corner-all ui-widget ui-state-active' title='<?php print __esc('Set/Refresh Filters', 'audit');?>'><?php print __esc('Go', 'audit');?></button>
<button type='button' id='clear' class='ui-button ui-corner-all ui-widget' title='<?php print __esc('Clear Filters', 'audit');?>'><?php print __esc('Clear', 'audit');?></button>
<button type='button' id='export' class='ui-button ui-corner-all ui-widget' title='<?php print __esc('Export Log Events', 'audit');?>'><?php print __esc('Export', 'audit');?></button>
<?php if (api_plugin_user_realm_auth('audit_manage.php')) {?>
<?php if (audit_user_is_admin()) {?>
<button type='button' id='purge' class='ui-button ui-corner-all ui-widget' data-confirm='<?php print __esc('Permanently purge all audit log events?', 'audit');?>' title='<?php print __esc('Purge Log Events', 'audit');?>'><?php print __esc('Purge', 'audit');?></button>
<?php }?>
</span>
Expand Down
121 changes: 117 additions & 4 deletions audit_functions.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<?php

function audit_user_is_admin() {
return api_plugin_user_realm_auth('audit_manage.php');
}

function audit_process_page_data($page, $drop_action, $selected_items) {
$objects = array();
if ($drop_action !== false) {
Expand Down Expand Up @@ -461,23 +465,131 @@ function audit_request_status($error = null, $status_code = 200) {
return 'completed';
}

function audit_finalize_request($id, $started_at = null) {
function audit_operation_verifier_for_request($page, $post) {
if ($page != 'user_admin.php' || !array_key_exists('save_component_realm_perms', $post)) {
return null;
}

$target_user_id = $post['id'] ?? null;
if (!is_scalar($target_user_id) ||
!preg_match('/^[1-9][0-9]*$/', (string) $target_user_id)) {
return array(
'type' => 'invalid',
'outcome_reason' => 'realm_permissions_request_invalid'
);
}

$expected_realm_ids = array();
foreach ($post as $field => $value) {
$field = (string) $field;

if (strpos($field, 'section') !== 0) {
continue;
}

if (!preg_match('/^section([1-9][0-9]*)$/', $field, $matches)) {
return array(
'type' => 'invalid',
'outcome_reason' => 'realm_permissions_request_invalid'
);
}

$expected_realm_ids[] = (int) $matches[1];
}

$expected_realm_ids = array_values(array_unique($expected_realm_ids));
sort($expected_realm_ids, SORT_NUMERIC);

return array(
'type' => 'user_realm_permissions',
'target_user_id' => (int) $target_user_id,
'expected_realm_ids' => $expected_realm_ids
);
}

function audit_verify_operation($verifier) {
if (!is_array($verifier) || empty($verifier['type'])) {
return array('outcome' => 'unknown', 'reason' => null);
}

if ($verifier['type'] == 'invalid') {
return array(
'outcome' => 'unknown',
'reason' => $verifier['outcome_reason'] ?? 'verification_request_invalid'
);
}

if ($verifier['type'] != 'user_realm_permissions') {
return array('outcome' => 'unknown', 'reason' => 'verification_type_unsupported');
}

$target_user_id = (int) ($verifier['target_user_id'] ?? 0);
$expected_realm_ids = $verifier['expected_realm_ids'] ?? array();
$user_count = db_fetch_cell_prepared('SELECT COUNT(*) FROM user_auth WHERE id = ?', array($target_user_id));

if ($user_count === false) {
return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed');
}

if ((int) $user_count !== 1) {
return array('outcome' => 'failure', 'reason' => 'target_user_not_found');
}

$rows = db_fetch_assoc_prepared('SELECT realm_id
FROM user_auth_realm
WHERE user_id = ?
ORDER BY realm_id',
array($target_user_id));

if (!is_array($rows)) {
return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed');
}

$actual_realm_ids = array();
foreach ($rows as $row) {
if (!isset($row['realm_id']) || !is_numeric($row['realm_id'])) {
return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed');
}

$actual_realm_ids[] = (int) $row['realm_id'];
}

$actual_realm_ids = array_values(array_unique($actual_realm_ids));
sort($actual_realm_ids, SORT_NUMERIC);

if ($actual_realm_ids === $expected_realm_ids) {
return array('outcome' => 'success', 'reason' => 'realm_permissions_verified');
}

return array('outcome' => 'failure', 'reason' => 'realm_permissions_mismatch');
}

function audit_finalize_request($id, $started_at = null, $verifier = null) {
$status_code = http_response_code();
$status_code = is_int($status_code) ? $status_code : 200;
$request_status = audit_request_status(error_get_last(), $status_code);
$outcome = $request_status == 'failed' ? 'failure' : 'unknown';
$outcome_reason = $request_status == 'failed' ? 'request_failed' : null;

if ($request_status == 'completed' && $verifier !== null) {
$verification = audit_verify_operation($verifier);
$outcome = $verification['outcome'];
$outcome_reason = $verification['reason'];
}

$duration_ms = $started_at === null ? null : max(0, (int) round((microtime(true) - $started_at) * 1000));
$completed_time = audit_utc_time();

db_execute_prepared("UPDATE audit_log
SET request_status = ?,
outcome_reason = CASE WHEN operation_outcome = 'unknown' THEN ? ELSE outcome_reason END,
operation_outcome = CASE WHEN operation_outcome = 'unknown' THEN ? ELSE operation_outcome END,
http_status = ?,
completed_time = ?,
duration_ms = ?
WHERE id = ?
AND request_status = 'started'",
array($request_status, $outcome, $status_code, $completed_time, $duration_ms, $id));
array($request_status, $outcome_reason, $outcome, $status_code, $completed_time, $duration_ms, $id));

$event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id));
if (cacti_sizeof($event)) {
Expand Down Expand Up @@ -580,8 +692,9 @@ function audit_config_insert() {
}

$target_id = $post['id'] ?? null;
$post = audit_json_encode($post);
$page = basename($_SERVER['SCRIPT_NAME']);
$verifier = audit_operation_verifier_for_request($page, $post);
$post = audit_json_encode($post);
$user_id = (isset($_SESSION['sess_user_id']) ? $_SESSION['sess_user_id'] : 0);
$event_time = audit_utc_time($started_at);

Expand Down Expand Up @@ -652,7 +765,7 @@ function audit_config_insert() {
$_SERVER['REQUEST_METHOD'] ?? null
));
$audit_id = db_fetch_insert_id();
register_shutdown_function('audit_finalize_request', $audit_id, $started_at);
register_shutdown_function('audit_finalize_request', $audit_id, $started_at, $verifier);

if ($external_logging && $audit_log == '') {
set_config_option('audit_log_external_path', $base . '/log/audit.log');
Expand Down
66 changes: 62 additions & 4 deletions setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,69 @@ function plugin_audit_install() {
/* hook for table replication */
api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php');

api_plugin_register_realm('audit', 'audit.php', __('View Cacti Audit Log', 'audit'), 1);
api_plugin_register_realm('audit', 'audit_manage.php', __('Manage Cacti Audit Log', 'audit'), 1);
audit_setup_realms(true);

audit_setup_table();
}

function audit_setup_realms($grant_installing_user = false) {
$realms = array(
'audit.php' => __('Audit Log User', 'audit'),
'audit_manage.php' => __('Audit Log Admin', 'audit')
);

foreach ($realms as $file => $display) {
api_plugin_register_realm('audit', $file, $display, $grant_installing_user ? 1 : 0);
}

if (!$grant_installing_user) {
$admin_user = (int) read_config_option('admin_user');

if ($admin_user > 0) {
$realm_ids = db_fetch_assoc_prepared('SELECT id + 100 AS realm_id
FROM plugin_realms
WHERE plugin = ?
AND file IN (?, ?)',
array('audit', 'audit.php', 'audit_manage.php'));

foreach ($realm_ids as $realm) {
db_execute_prepared('REPLACE INTO user_auth_realm
(user_id, realm_id)
VALUES (?, ?)',
array($admin_user, $realm['realm_id']));
}
}
}
}

function audit_remove_deprecated_realms() {
$realms = db_fetch_assoc_prepared('SELECT id
FROM plugin_realms
WHERE plugin = ?
AND file = ?',
array('audit', 'audit_purge.php'));

foreach ($realms as $realm) {
$realm_id = $realm['id'] + 100;

db_execute_prepared('DELETE FROM user_auth_realm
WHERE realm_id = ?',
array($realm_id));

db_execute_prepared('DELETE FROM user_auth_group_realm
WHERE realm_id = ?',
array($realm_id));

db_execute_prepared('DELETE FROM plugin_realms
WHERE id = ?',
array($realm['id']));
}

if (cacti_sizeof($realms)) {
api_plugin_replicate_config();
}
}

function plugin_audit_uninstall() {
db_execute('DROP TABLE IF EXISTS audit_log');
return true;
Expand Down Expand Up @@ -103,6 +160,8 @@ function audit_check_upgrade() {
db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data");
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_realms();
audit_remove_deprecated_realms();

db_execute_prepared('UPDATE plugin_config
SET version = ?
Expand All @@ -121,7 +180,6 @@ function audit_check_upgrade() {
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_realm('audit', 'audit_manage.php', __('Manage Cacti Audit Log', 'audit'), 1);
}
}

Expand Down Expand Up @@ -378,7 +436,7 @@ function audit_config_arrays() {
$menu[__('Utilities')]['plugins/audit/audit.php'] = __('Audit Log', 'audit');

if (function_exists('auth_augment_roles')) {
auth_augment_roles(__('System Administration'), array('audit.php'));
auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'));
}

audit_check_upgrade();
Expand Down
40 changes: 38 additions & 2 deletions tests/controller_security_test.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<?php

$controller = file_get_contents(dirname(__DIR__) . '/audit.php');
$functions = file_get_contents(dirname(__DIR__) . '/audit_functions.php');
$javascript = file_get_contents(dirname(__DIR__) . '/js/functions.js');
$setup = file_get_contents(dirname(__DIR__) . '/setup.php');

$required_controller_guards = array(
"\$_SERVER['REQUEST_METHOD'] !== 'POST'",
"api_plugin_user_realm_auth('audit_manage.php')",
'audit_user_is_admin()',
'csrf_check(false)',
'html_escape($data',
"__('Outcome Reason:', 'audit')",
"header('Content-Type: text/csv; charset=UTF-8')",
"fputcsv("
);
Expand All @@ -21,7 +23,12 @@
}

$required_schema_fragments = array(
'api_plugin_register_realm(\'audit\', \'audit_manage.php\'',
"'audit.php' => __('Audit Log User'",
"'audit_manage.php' => __('Audit Log Admin'",
'audit_setup_realms(true)',
'audit_setup_realms()',
'audit_remove_deprecated_realms()',
"auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'))",
'api_plugin_register_hook(\'audit\', \'replicate_out\'',
'request_status',
'ADD COLUMN IF NOT EXISTS external_status',
Expand All @@ -41,6 +48,35 @@
}
}

$required_verifier_fragments = array(
'audit_operation_verifier_for_request',
"'user_realm_permissions'",
"'realm_permissions_verified'",
"register_shutdown_function('audit_finalize_request', \$audit_id, \$started_at, \$verifier)"
);

foreach ($required_verifier_fragments as $fragment) {
if (strpos($functions, $fragment) === false) {
fwrite(STDERR, 'Missing operation verification requirement: ' . $fragment . PHP_EOL);
exit(1);
}
}

if (strpos($functions, "api_plugin_user_realm_auth('audit_manage.php')") === false) {
fwrite(STDERR, 'Audit administrators must be authorized to purge.' . PHP_EOL);
exit(1);
}

if (strpos($functions, "api_plugin_user_realm_auth('audit_purge.php')") !== false) {
fwrite(STDERR, 'The deprecated delegated purge permission must not authorize purge.' . PHP_EOL);
exit(1);
}

if (substr_count($controller, 'audit_user_is_admin()') < 2) {
fwrite(STDERR, 'Purge authorization must protect both the action and its UI control.' . PHP_EOL);
exit(1);
}

if (strpos($javascript, "loadPageNoHeader('audit.php?action=purge") !== false) {
fwrite(STDERR, 'Purge must not use the legacy GET request path.' . PHP_EOL);
exit(1);
Expand Down
Loading