feat(auth): log administrative account actions after confirmed writes (#8360)

* log user action (create, delete, locked, ..)

* change log

* update changelog

* style(auth): apply coding standards to user action logging

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* Audit password changes only after successful account saves

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(auth): sanitize audit fields and confirm account removals

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: cover native account audit state transitions

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: synchronize native coverage fixture dispatch

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(auth): audit only confirmed account persistence

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: measure coverage at the exact PR revision

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: Petr Macek <petr.macek@kostax.cz>
This commit is contained in:
Thomas VincentandPetr Macek authored and GitHub committed 2026-10-14 09:54:14 -04:00
1 parent a634323d1d
commit 930b085703
14 files changed
+1071 -3

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
name: Changed PHP Coverage
on:
pull_request:
paths:
- '**.php'
- 'phpunit.xml'
- 'tests/e2e/**'
- 'tests/Helpers/RequestCoverage.php'
- 'tests/tools/check_changed_php_coverage.py'
- 'tests/tools/test_check_changed_php_coverage.py'
- 'tests/tools/run_request_coverage.sh'
- 'tests/tools/test_request_coverage_dispatch.py'
- '.github/workflows/pr-php-coverage.yml'
permissions:
contents: read
concurrency:
group: changed-php-coverage-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
coverage:
name: Changed PHP lines >= 80%
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
- uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
with:
tool_versions: node 22.22.2
install_args: node
- uses: ./.github/actions/setup-php-composer
with:
php-version: '8.3'
coverage: xdebug
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
ini-values: memory_limit=2G
dependency-mode: locked-pr-coverage
install-args: --prefer-dist --no-progress --no-interaction
- name: Validate the coverage gate and include every changed PHP file
env:
BASE_COMMIT: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
python3 tests/tools/test_check_changed_php_coverage.py
python3 tests/tools/test_request_coverage_dispatch.py
python3 tests/tools/check_changed_php_coverage.py --base "$BASE_COMMIT" \
--prepare-config phpunit.xml --output-config phpunit-changed.xml
- name: Exercise unit and integration contracts with statement coverage
env:
XDEBUG_MODE: coverage
run: include/vendor/bin/pest --configuration=phpunit-changed.xml --testsuite=Unit,Integration --coverage-clover=coverage-changed.xml
- name: Exercise applicable browser and database contracts
env:
BASE_COMMIT: ${{ github.event.pull_request.base.sha }}
CACTI_TEST_COVERAGE: '1'
CACTI_PR_RAW_COVERAGE: /tmp/cacti-pr-coverage
CACTI_PR_COVERAGE_OUTPUT: ${{ runner.temp }}/request-coverage
run: bash tests/tools/run_request_coverage.sh
- name: Require at least 80 percent of changed executable PHP lines
env:
BASE_COMMIT: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
request_args=()
if compgen -G "${{ runner.temp }}/request-coverage/*.json" > /dev/null; then
request_args=(--request-reports "${{ runner.temp }}/request-coverage")
fi
python3 tests/tools/check_changed_php_coverage.py --base "$BASE_COMMIT" \
--report coverage-changed.xml "${request_args[@]}" \
--minimum 80 --json-output coverage-changed.json
- name: Preserve coverage evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: changed-php-coverage
path: |
coverage-changed.xml
coverage-changed.json
${{ runner.temp }}/request-coverage
if-no-files-found: warn
+1
View File
@@ -413,6 +413,7 @@ Cacti CHANGELOG
-feature#7372: Add installer domain foundation types and lifecycle tests -feature#7372: Add installer domain foundation types and lifecycle tests
-feature#7634: Add indexes for host-template and session lookup queries -feature#7634: Add indexes for host-template and session lookup queries
-feature#7681: Add fail2ban support with a stable authentication-failure log line and a bundled filter -feature#7681: Add fail2ban support with a stable authentication-failure log line and a bundled filter
-feature#8281: Log user actions (create, delete, ...)
1.2.32 1.2.32
-security#GHSA-fq9x-x3vf-3vf2: Substitute data-input field tokens in a single pass so a composed value cannot re-inject an escaped payload into the script command line -security#GHSA-fq9x-x3vf-3vf2: Substitute data-input field tokens in a single pass so a composed value cannot re-inject an escaped payload into the script command line
+6
View File
@@ -2348,6 +2348,8 @@ function _db_replace(mixed $db_conn, string $table, array $fieldArray, mixed $ke
if (!$return_code) { if (!$return_code) {
cacti_log("ERROR: SQL Save Failed for Table '$table'. SQL:'" . clean_up_lines($sql) . "'", false, 'DBCALL'); cacti_log("ERROR: SQL Save Failed for Table '$table'. SQL:'" . clean_up_lines($sql) . "'", false, 'DBCALL');
return false;
} }
return db_fetch_insert_id($db_conn); return db_fetch_insert_id($db_conn);
@@ -2429,6 +2431,10 @@ function sql_save(array $array_items, string $table_name, mixed $key_cols = 'id'
$replace_result = _db_replace($db_conn, $table_name, $array_items, $key_cols); $replace_result = _db_replace($db_conn, $table_name, $array_items, $key_cols);
if ($replace_result === false) {
return false;
}
// get the last AUTO_ID and return it // get the last AUTO_ID and return it
if (!$replace_result || db_fetch_insert_id($db_conn) == '0') { if (!$replace_result || db_fetch_insert_id($db_conn) == '0') {
if (!is_array($key_cols)) { if (!is_array($key_cols)) {
+47
View File
@@ -10805,3 +10805,50 @@ function validate_relative_path_within(mixed $path, string $base_dir) : mixed {
return $candidate; return $candidate;
} }
/**
* Log an administrative action performed on a user account.
*
* @param mixed $user Username of the affected account.
* @param mixed $action Action that was performed.
*
* @return bool Whether the message was written to the Cacti log.
*/
function log_user_action($user, $action) {
if (!is_string($user) || !is_string($action)) {
return false;
}
$clean = static function (string $value) : string {
return preg_replace('/[\x00-\x1f\x7f]/', '', $value) ?? '';
};
$user = $clean($user);
$action = $clean($action);
if ($user === '' || $action === '') {
return false;
}
if (!empty($_SESSION['sess_user_id'])) {
$acting_user_id = $_SESSION['sess_user_id'];
$acting_user = get_username($acting_user_id);
if (!is_string($acting_user) || $acting_user === '') {
$acting_user = 'user ID ' . $acting_user_id;
}
} elseif (PHP_SAPI === 'cli') {
$acting_user = get_execution_user();
if ($acting_user === '') {
$acting_user = 'unknown user';
}
$acting_user .= ' (CLI)';
} else {
$acting_user = 'unknown user';
}
$acting_user = $clean($acting_user);
return cacti_log("NOTE: User '$user' was $action by user '$acting_user'", false, PHP_SAPI === 'cli' ? 'AUTHC' : 'AUTH');
}
+47
View File
@@ -0,0 +1,47 @@
<?php
// Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
// Loaded only by the disposable E2E image when coverage is explicitly enabled.
$requestCoverageDirectory = getenv('CACTI_PR_RAW_COVERAGE');
if ($requestCoverageDirectory !== false && $requestCoverageDirectory !== '') {
if (!function_exists('xdebug_start_code_coverage')) {
throw new RuntimeException('Request coverage requires Xdebug.');
}
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);
register_shutdown_function(static function () use ($requestCoverageDirectory) : void {
$data = xdebug_get_code_coverage();
foreach ($GLOBALS['cacti_fixture_source_map'] ?? [] as $temporary => $original) {
foreach ($data[$temporary] ?? [] as $line => $hits) {
$data[$original][$line] = max($hits, $data[$original][$line] ?? -2);
}
unset($data[$temporary]);
}
$root = realpath(dirname(__DIR__, 2));
$hashes = [];
$htmlSpans = [];
foreach ($data as $file => $lines) {
if (!str_starts_with($file, $root . '/') || str_contains($file, '/vendor/')) {
unset($data[$file]);
continue;
}
$hashes[$file] = hash_file('sha256', $file);
$htmlSpans[$file] = [];
// Zend records a literal HTML output opcode at the end of its block.
foreach (token_get_all(file_get_contents($file)) as $token) {
if (is_array($token) && $token[0] === T_INLINE_HTML) {
$htmlSpans[$file][] = [$token[2], $token[2] + substr_count($token[1], "\n")];
}
}
}
$path = $requestCoverageDirectory . '/' . getmypid() . '-' . bin2hex(random_bytes(8)) . '.json';
if (file_put_contents($path, json_encode(['root' => $root, 'coverage' => $data, 'hashes' => $hashes, 'html_spans' => $htmlSpans], JSON_THROW_ON_ERROR)) === false) {
throw new RuntimeException('Could not preserve request coverage.');
}
});
}
@@ -56,3 +56,44 @@ it('REPLACE-INTO semantics keep a single row keyed by name', function () {
->and($rows[0]['name'])->toBe('opt') ->and($rows[0]['name'])->toBe('opt')
->and($rows[0]['value'])->toBe('v2'); ->and($rows[0]['value'])->toBe('v2');
}); });
it('returns false for a failed update instead of the existing or stale insert id', function () {
sql_save(['hostname' => 'host1'], 'host', 'id', true, $this->conn);
$this->conn->exec("CREATE TRIGGER reject_host_save BEFORE INSERT ON host BEGIN SELECT RAISE(FAIL, 'save rejected'); END");
$result = sql_save(['id' => 1, 'hostname' => 'renamed'], 'host', 'id', true, $this->conn);
expect($result)->toBeFalse()
->and($this->conn->query('SELECT hostname FROM host WHERE id = 1')->fetchColumn())->toBe('host1');
});
it('returns false for a failed insert even when the connection has an earlier insert id', function () {
sql_save(['hostname' => 'host1'], 'host', 'id', true, $this->conn);
$this->conn->exec("CREATE TRIGGER reject_host_save BEFORE INSERT ON host BEGIN SELECT RAISE(FAIL, 'save rejected'); END");
expect(sql_save(['hostname' => 'host2'], 'host', 'id', true, $this->conn))->toBeFalse()
->and((int) $this->conn->query('SELECT COUNT(*) FROM host')->fetchColumn())->toBe(1);
});
it('keeps the existing key fallback after a successful write with no new insert id', function () {
$conn = new class extends FakeMySQLPDO {
public function lastInsertId(?string $name = null): string|false {
return '0';
}
public function prepare(string $query, array $options = []): PDOStatement|false {
// MySQL reports numeric column types in lowercase; SQLite's
// metadata otherwise treats this primary key as a quoted string.
if ($query === 'SHOW COLUMNS FROM host') {
$query = "SELECT name AS Field, lower(type) AS Type, 'NO' AS `Null`, '' AS `Default`, '' AS Extra FROM pragma_table_info('host')";
}
return parent::prepare($query, $options);
}
};
$conn->exec('CREATE TABLE host (id INTEGER PRIMARY KEY, hostname TEXT NOT NULL)');
$conn->exec("INSERT INTO host VALUES (1, 'host1')");
expect((int) sql_save(['id' => 1, 'hostname' => 'renamed'], 'host', 'id', true, $conn))->toBe(1)
->and($conn->query('SELECT hostname FROM host WHERE id = 1')->fetchColumn())->toBe('renamed');
});
+22
View File
@@ -0,0 +1,22 @@
<?php
// Copyright (C) 2026 The Cacti Group. GPL-2.0-or-later.
require dirname(__DIR__) . '/Helpers/IsolatedProbe.php';
$arguments = [];
if (getenv('CACTI_PR_XDEBUG_EXTENSION')) {
$arguments = ['-d', 'zend_extension=' . getenv('CACTI_PR_XDEBUG_EXTENSION')];
}
foreach (['edit', 'password', 'failed-save', 'failed-save-id', 'save-read-failure', 'save-missing', 'mismatch', 'new', 'audit-controls', 'delete', 'delete-template', 'delete-guest', 'delete-failure', 'delete-read-failure', 'delete-missing', 'enable', 'disable', 'lock', 'unlock', 'audit-invalid', 'audit-unknown', 'audit-cli', 'bulk-enable', 'bulk-disable', 'bulk-enable-failure', 'bulk-disable-failure', 'bulk-enable-read-failure', 'bulk-disable-read-failure', 'bulk-enable-missing', 'bulk-disable-missing', 'bulk-enable-noop', 'bulk-disable-noop'] as $scenario) {
$process = proc_open(array_merge([PHP_BINARY], $arguments, [__DIR__ . '/UserAdministrationAuditProbe.php', $scenario]), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if (proc_close($process) !== 0 || !is_array(json_decode($output, true))) {
throw new RuntimeException($scenario . ': ' . $error);
}
}
print "PASS: native account save and audit scenarios\n";
+235
View File
@@ -0,0 +1,235 @@
<?php
// Copyright (C) 2026 The Cacti Group. Licensed under GPL-2.0-or-later.
namespace CactiUserAuditFixture;
use PDO;
use RuntimeException;
require dirname(__DIR__) . '/Helpers/RequestCoverage.php';
require dirname(__DIR__) . '/Helpers/SourceSlice.php';
// Run the real save handler and audit helper with an isolated SQLite save boundary.
$scenario = $argv[1] ?? 'edit';
$root = dirname(__DIR__, 2);
require $root . '/include/global_constants.php';
$pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE user_auth (id INTEGER PRIMARY KEY, username TEXT, password TEXT, enabled TEXT, locked TEXT)');
$old_hash = password_hash('old password', PASSWORD_DEFAULT);
$statement = $pdo->prepare('INSERT INTO user_auth VALUES (10, ?, ?, ?, ?)');
$statement->execute(['target', $old_hash, 'on', '']);
$logs = [];
$messages = [];
$_SESSION = ['sess_user_id' => 1];
$request = ['save_component_user' => '1', 'id' => $scenario === 'new' ? 0 : 10, 'username' => 'target', 'realm' => 0, 'enabled' => 'on', 'locked' => '', 'password' => '', 'password_confirm' => ''];
if ($scenario === 'enable' || str_starts_with($scenario, 'bulk-enable')) {
$pdo->exec("UPDATE user_auth SET enabled = '' WHERE id = 10");
} elseif ($scenario === 'disable') {
$request['enabled'] = '';
} elseif ($scenario === 'lock') {
$request['locked'] = 'on';
} elseif ($scenario === 'unlock') {
$pdo->exec("UPDATE user_auth SET locked = 'on' WHERE id = 10");
}
if (in_array($scenario, ['password', 'failed-save', 'failed-save-id', 'save-read-failure', 'save-missing', 'mismatch'], true)) {
$request['password'] = 'new password';
$request['password_confirm'] = $scenario === 'mismatch' ? 'different password' : 'new password';
}
function load_audit_function($file, $name) {
cacti_fixture_source_function($file, $name, __NAMESPACE__);
}
function gnrv($key, $default = '') {
return $GLOBALS['request'][$key] ?? $default;
}
function grv($key, $default = '') {
return gnrv($key, $default);
}
function gfrv($key, $default = 0) {
return (int) gnrv($key, $default);
}
function get_nfilter_request_var($key) {
return gnrv($key);
}
function isrv($key) {
return isset($GLOBALS['request'][$key]);
}
function is_error_message() {
return $GLOBALS['messages'] !== [];
}
function raise_message($message, ...$args) {
if ($message !== 1) {
$GLOBALS['messages'][] = $message;
}
}
function read_config_option($key) {
return $key === 'admin_user' ? 1 : '';
}
function is_template_account($id) {
return $GLOBALS['scenario'] === 'delete-template';
}
function get_guest_account() {
return $GLOBALS['scenario'] === 'delete-guest' ? 10 : 0;
}
function input_validate_input_number(...$args) {
}
function cacti_count($value) {
return count($value);
}
function sanitize_unserialize_selected_items($value) {
return unserialize($value, ['allowed_classes' => false]);
}
function cacti_sizeof($value) {
return is_countable($value) ? count($value) : 0;
}
function compat_password_hash($password, $algorithm) {
return password_hash($password, $algorithm);
}
function form_input_validate($value, ...$args) {
return $value;
}
function db_fetch_row_prepared($sql, $params = []) {
if ($GLOBALS['scenario'] === 'delete-read-failure' && str_contains($sql, 'SELECT id')) {
return false;
}
if (str_contains($sql, 'SELECT id, username, password, enabled, locked')) {
if (in_array($GLOBALS['scenario'], ['save-read-failure', 'bulk-enable-read-failure', 'bulk-disable-read-failure'], true)) {
return false;
}
if ($GLOBALS['scenario'] === 'save-missing') {
return [];
}
}
if (str_starts_with($sql, 'SELECT username, enabled') && !empty($GLOBALS['bulk_mutated']) && str_ends_with($GLOBALS['scenario'], '-read-failure')) {
return false;
}
if (str_contains($sql, 'realm = ?')) {
return [];
}
$statement = $GLOBALS['pdo']->prepare('SELECT * FROM user_auth WHERE id = ?');
$statement->execute([$params[0]]);
return $statement->fetch(PDO::FETCH_ASSOC) ?: [];
}
function db_fetch_cell_prepared($sql, $params = []) {
if (str_contains($sql, 'password_history')) {
return '';
}
$row = db_fetch_row_prepared($sql, $params);
return $row[str_contains($sql, 'SELECT password') ? 'password' : 'username'] ?? '';
}
function db_execute_prepared($sql, $params = []) {
if (str_starts_with($sql, 'UPDATE user_auth SET enabled')) {
$GLOBALS['bulk_mutated'] = true;
if (str_ends_with($GLOBALS['scenario'], '-failure') && !str_ends_with($GLOBALS['scenario'], '-read-failure')) {
return false;
}
$statement = $GLOBALS['pdo']->prepare($sql);
return $statement->execute($params);
}
if (str_starts_with($sql, 'DELETE FROM user_auth WHERE')) {
if ($GLOBALS['scenario'] === 'delete-failure') {
return false;
}
$statement = $GLOBALS['pdo']->prepare($sql);
return $statement->execute($params);
}
return true;
}
function api_plugin_hook_function($hook, $save) {
return $save;
}
function sql_save($save, $table) {
if ($GLOBALS['scenario'] === 'failed-save') {
return false;
}
// The legacy production helper could return the existing ID on write
// failure. Keep that case to verify the handler also confirms persistence.
if ($GLOBALS['scenario'] === 'failed-save-id') {
return $save['id'];
}
$id = $save['id'] ?: 11;
$statement = $GLOBALS['pdo']->prepare('INSERT OR REPLACE INTO user_auth VALUES (?, ?, ?, ?, ?)');
$statement->execute([$id, $save['username'], $save['password'], $save['enabled'], $save['locked']]);
return $id;
}
function get_username($id) {
if ($id === 1) {
return $GLOBALS['scenario'] === 'audit-controls' ? "admin\r\n\x00\x1b\x7f" : 'admin';
}
return db_fetch_cell_prepared('SELECT username FROM user_auth WHERE id = ?', [$id]);
}
function cacti_log($message, ...$args) {
$GLOBALS['logs'][] = $message;
return true;
}
function reset_user_perms($id) {
}
load_audit_function($root . '/user_admin.php', 'form_save');
load_audit_function($root . '/lib/functions.php', 'log_user_action');
load_audit_function($root . '/lib/functions.php', 'get_execution_user');
if (str_starts_with($scenario, 'audit-') && $scenario !== 'audit-controls') {
if ($scenario === 'audit-invalid') {
foreach ([[[], 'edited'], ['target', []], ["\r\n", 'edited'], ['target', "\x00"]] as [$user, $action]) {
if (log_user_action($user, $action) !== false) {
throw new RuntimeException('Invalid audit input was logged');
}
}
} else {
$_SESSION = $scenario === 'audit-cli' ? [] : ['sess_user_id' => 404];
log_user_action('target', 'edited');
}
print json_encode(['logs' => $logs], JSON_THROW_ON_ERROR) . "\n";
exit;
}
if ($scenario === 'audit-controls') {
log_user_action("target\r\n\x00\x1b\x7f", "edited\r\n\x00\x1b\x7f");
print json_encode(['logs' => $logs], JSON_THROW_ON_ERROR) . "\n";
exit;
}
if (str_starts_with($scenario, 'delete') || str_starts_with($scenario, 'bulk-')) {
load_audit_function($root . '/user_admin.php', 'form_actions');
load_audit_function($root . '/lib/auth.php', 'user_remove');
load_audit_function($root . '/lib/auth.php', 'user_enable');
load_audit_function($root . '/lib/auth.php', 'user_disable');
$request = ['selected_items' => serialize([10]), 'drp_action' => str_starts_with($scenario, 'bulk-enable') ? '3' : (str_starts_with($scenario, 'bulk-disable') ? '4' : '1')];
if ($scenario === 'delete-missing') {
$pdo->exec('DELETE FROM user_auth WHERE id = 10');
}
if (str_ends_with($scenario, '-missing')) {
$pdo->exec('DELETE FROM user_auth WHERE id = 10');
}
if ($scenario === 'bulk-enable-noop') {
$pdo->exec("UPDATE user_auth SET enabled = 'on' WHERE id = 10");
} elseif ($scenario === 'bulk-disable-noop') {
$pdo->exec("UPDATE user_auth SET enabled = '' WHERE id = 10");
}
register_shutdown_function(static function () use ($pdo, &$logs, &$messages): void {
print json_encode(['logs' => $logs, 'remaining' => (int) $pdo->query('SELECT COUNT(*) FROM user_auth WHERE id = 10')->fetchColumn(), 'enabled' => $pdo->query('SELECT enabled FROM user_auth WHERE id = 10')->fetchColumn(), 'errors' => $messages], JSON_THROW_ON_ERROR) . "\n";
});
form_actions();
}
form_save();
$saved = $pdo->query('SELECT password FROM user_auth WHERE id = ' . ($scenario === 'new' ? 11 : 10))->fetchColumn();
print json_encode(['logs' => $logs, 'password_changed' => $saved !== $old_hash, 'new_password_matches' => password_verify('new password', $saved), 'errors' => $messages], JSON_THROW_ON_ERROR) . "\n";
@@ -0,0 +1,84 @@
<?php
// Copyright (C) 2026 The Cacti Group. Licensed under GPL-2.0-or-later.
require_once dirname(__DIR__) . '/Helpers/IsolatedProbe.php';
test('account save audits a password change only after it is persisted', function (string $scenario, bool $changed) {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
$events = array_values(array_filter($r['logs'], static fn ($log) => str_contains($log, 'changed password')));
expect($events)->toHaveCount($changed ? 1 : 0)->and($r['password_changed'])->toBe($changed);
if ($changed) {
expect($r['new_password_matches'])->toBeTrue();
}
})->with([
'ordinary edit' => ['edit', false],
'password change' => ['password', true],
'failed database save' => ['failed-save', false],
'password mismatch' => ['mismatch', false]
]);
test('saved enabled and locked transitions identify the affected account and actor', function ($scenario, $action) {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
expect($r['logs'])->toContain("NOTE: User 'target' was $action by user 'admin'");
})->with([['enable', 'enabled'], ['disable', 'disabled'], ['lock', 'locked'], ['unlock', 'unlocked']]);
test('invalid audit inputs produce no log records', function () {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['audit-invalid']);
expect($r['logs'])->toBe([]);
});
test('an unavailable actor is identified by account ID', function () {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['audit-unknown']);
expect($r['logs'])->toBe(["NOTE: User 'target' was edited by user 'user ID 404'"]);
});
test('CLI audit records identify the execution user', function () {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['audit-cli']);
expect($r['logs'])->toHaveCount(1);
expect($r['logs'][0])->toContain("User 'target' was edited")->toContain(' (CLI)');
});
test('new accounts emit creation rather than an existing-account password-change event', function () {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['new']);
expect(implode("\n", $r['logs']))->toContain('was created')->not->toContain('changed password');
});
test('account audit fields cannot inject log lines or terminal controls', function () {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', ['audit-controls']);
expect($r['logs'])->toBe(["NOTE: User 'target' was edited by user 'admin'"]);
});
test('account removals are audited only when the existing row is confirmed deleted', function (string $scenario, int $remaining, bool $audited) {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
expect($r['remaining'])->toBe($remaining)
->and($r['logs'])->toBe($audited ? ["NOTE: User 'target' was removed by user 'admin'"] : []);
})->with([
'deleted account' => ['delete', 0, true],
'template account' => ['delete-template', 1, false],
'guest account' => ['delete-guest', 1, false],
'failed deletion' => ['delete-failure', 1, false],
'failed confirmation read' => ['delete-read-failure', 0, false],
'missing account' => ['delete-missing', 0, false]
]);
test('unconfirmed account saves emit no success audit events', function (string $scenario) {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
expect($r['logs'])->toBe([]);
})->with(['failed-save', 'failed-save-id', 'save-read-failure', 'save-missing']);
test('bulk account transitions are audited only after the stored state changes', function (string $scenario, string|false $enabled, string|false $action) {
$r = cacti_test_isolated_probe(dirname(__DIR__) . '/fixtures/UserAdministrationAuditProbe.php', [$scenario]);
expect($r['enabled'])->toBe($enabled)
->and($r['logs'])->toBe($action === false ? [] : ["NOTE: User 'target' was $action by user 'admin'"]);
})->with([
['bulk-enable', 'on', 'enabled'],
['bulk-disable', '', 'disabled'],
['bulk-enable-failure', '', false],
['bulk-disable-failure', 'on', false],
['bulk-enable-read-failure', 'on', false],
['bulk-disable-read-failure', '', false],
['bulk-enable-missing', false, false],
['bulk-disable-missing', false, false],
['bulk-enable-noop', 'on', false],
['bulk-disable-noop', '', false],
]);
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Require measured statement coverage for PHP lines added by a pull request.
Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
def added_lines(base, root):
merge_base = subprocess.check_output(
['git', 'merge-base', base, 'HEAD'], cwd=root, text=True).strip()
diff = subprocess.check_output(
['git', '-c', 'core.quotePath=false', 'diff', '--no-ext-diff',
'--no-textconv', '--unified=0', merge_base, 'HEAD', '--', '*.php',
':(exclude)tests/**', ':(exclude)include/vendor/**'], cwd=root, text=True)
changed = {}
name = None
line = 0
for record in diff.splitlines():
if record.startswith('diff --git '):
name = None
elif record.startswith('+++ b/'):
name = record[6:]
changed.setdefault(name, set())
elif record.startswith('@@'):
line = int(re.search(r'\+(\d+)', record).group(1))
elif record.startswith('+') and name is not None:
changed[name].add(line)
line += 1
elif not record.startswith(('-', '\\')):
line += 1
return {name: lines for name, lines in changed.items() if lines}
def prepare_config(config, output, changed):
"""Keep the test suite, but include every changed production PHP file."""
tree = ET.parse(config)
source = tree.getroot().find('source')
if source is None:
source = ET.SubElement(tree.getroot(), 'source')
source.clear()
include = ET.SubElement(source, 'include')
for name in sorted(changed):
ET.SubElement(include, 'file').text = name
ET.indent(tree, space=' ')
tree.write(output, encoding='utf-8', xml_declaration=True)
def measure(changed, report, root, request_reports=None):
evidence = {}
for file in ET.parse(report).iter('file'):
try:
name = str(Path(file.attrib['name']).resolve().relative_to(root.resolve()))
except ValueError:
continue
statements = evidence.setdefault(name, {})
for item in file.findall('line'):
if item.attrib.get('type') == 'stmt':
number = int(item.attrib['num'])
statements[number] = max(statements.get(number, 0), int(item.attrib['count']))
if request_reports:
reports = list(request_reports.glob('*.json'))
if not reports:
raise ValueError('Request coverage directory contains no reports')
for path in reports:
request = json.loads(path.read_text())
for file, lines in (request['coverage'] or {}).items():
name = str(Path(file).relative_to(request['root']))
if name not in changed:
continue
if hashlib.sha256((root / name).read_bytes()).hexdigest() != request['hashes'].get(file):
raise ValueError(f'Request coverage source does not match {name}')
statements = evidence.get(name, {})
for number, hits in (lines or {}).items():
number = int(number)
# The application bootstrap runs before PHPUnit starts its driver.
# Its native request instructions must still enter the denominator.
if name == 'include/global.php' and number in changed[name] and hits != -2:
statements.setdefault(number, 0)
if number in statements and hits > 0:
statements[number] = max(statements[number], hits)
for start, end in request.get('html_spans', {}).get(file, []):
if (lines or {}).get(str(end), 0) > 0:
for number in changed[name] & statements.keys():
if start <= number <= end:
statements[number] = max(statements[number], 1)
files = {}
for name, added in changed.items():
if name not in evidence:
files[name] = {'missing': True}
continue
statements = evidence[name]
if name == 'include/global.php':
source_lines = (root / name).read_text().splitlines()
for number in added:
if re.match(r'^\s*(?:\$|(?:return|throw|if|elseif|foreach|while|for|print|echo|require|require_once|include|include_once)\b|[A-Za-z_\\][\w\\]*\()', source_lines[number - 1]):
statements.setdefault(number, 0)
executable = added & statements.keys()
covered = {number for number in executable if statements[number] > 0}
files[name] = {
'covered': len(covered), 'executable': len(executable),
'uncovered': sorted(executable - covered),
}
covered = sum(item.get('covered', 0) for item in files.values())
total = sum(item.get('executable', 0) for item in files.values())
return {
'covered': covered, 'executable': total,
'percent': covered / total * 100 if total else None, 'files': files,
}
def passes(result, minimum):
if any(item.get('missing') for item in result['files'].values()):
return False
return result['executable'] == 0 or result['covered'] * 100 >= minimum * result['executable']
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base', required=True)
parser.add_argument('--prepare-config', type=Path)
parser.add_argument('--output-config', type=Path)
parser.add_argument('--report', type=Path)
parser.add_argument('--request-reports', type=Path)
parser.add_argument('--minimum', type=float, default=80)
parser.add_argument('--json-output', type=Path)
args = parser.parse_args()
if not 0 <= args.minimum <= 100:
parser.error('--minimum must be between 0 and 100')
root = Path.cwd()
changed = added_lines(args.base, root)
if args.prepare_config:
if not args.output_config:
parser.error('--prepare-config requires --output-config')
prepare_config(args.prepare_config, args.output_config, changed)
print(f'Coverage scope includes {len(changed)} changed production PHP files.')
return 0
if not args.report:
parser.error('--report is required when measuring coverage')
result = measure(changed, args.report, root, args.request_reports)
output = json.dumps(result, indent=2)
print(output)
if args.json_output:
args.json_output.write_text(output + '\n')
return 0 if passes(result, args.minimum) else 1
if __name__ == '__main__':
try:
sys.exit(main())
except (OSError, ValueError, KeyError, ET.ParseError, subprocess.CalledProcessError) as error:
print(f'Changed PHP coverage could not be verified: {error}', file=sys.stderr)
sys.exit(1)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
set -euo pipefail
: "${BASE_COMMIT:?Coverage needs the pull request base commit}"
: "${CACTI_PR_COVERAGE_OUTPUT:?Coverage needs an evidence directory}"
mkdir -p "$CACTI_PR_COVERAGE_OUTPUT"
command -v grep > /dev/null
merge_base="$(git merge-base "$BASE_COMMIT" HEAD)"
changed_paths="$(git diff --name-only "$merge_base" HEAD)"
run_probe() {
local script="$1" pattern="$2" port="$3"
if [[ -f "$script" ]] && grep -Eq "$pattern" <<< "$changed_paths"; then
HOST_PORT="$port" bash "$script"
fi
}
run_probe tests/e2e/aggregate-item-overrides.sh '^(aggregate_(graphs|templates)\.php|lib/api_aggregate\.php|install/upgrades/1_3_0\.php|tests/e2e/aggregate-item-overrides)' 18273
CACTI_AGGREGATE_TEST_BROWSER=1 run_probe tests/e2e/aggregate-automation.sh '^(automation_aggregates\.php|lib/(api_automation|automation|api_aggregate)\.php|install/upgrades/1_3_0\.php|tests/e2e/aggregate-automation)' 18274
run_probe tests/e2e/accessibility-coverage.sh '^((about|aggregate_graphs|auth_changepassword|auth_resetpassword|automation_templates|color|data_queries|data_templates|graph_realtime|graph_xport|graphs_new|host|host_templates|index|package|permission_denied|plugins|tree|user_admin|user_group_admin|utilities)\.php|include/top_(general_|graph_)?header\.php|install/install\.php|lib/html(_form|_graph)?\.php|tests/e2e/accessibility)' 18275
run_probe tests/e2e/queue/run.sh '^(lib/api_queue\.php|cli/queue_(admin|worker)\.php|include/global_settings\.php|install/upgrades/1_3_0\.php|tests/e2e/queue/)' 18276
run_php_probe() {
local script="$1" pattern="$2"
local php_args=(-d memory_limit=2G)
if [[ -n "${CACTI_PR_XDEBUG_EXTENSION:-}" ]]; then
php_args+=(-d "zend_extension=$CACTI_PR_XDEBUG_EXTENSION")
fi
if [[ -f "$script" ]] && grep -Eq "$pattern" <<< "$changed_paths"; then
CACTI_PR_RAW_COVERAGE="$CACTI_PR_COVERAGE_OUTPUT" XDEBUG_MODE=coverage php "${php_args[@]}" "$script"
fi
}
run_php_probe tests/fixtures/Core/Auth/BoundaryContracts.php '^(auth_changepassword\.php|include/global\.php|lib/auth\.php|package_import\.php|tests/fixtures/Core/Auth/BoundaryContracts\.php)'
run_php_probe tests/fixtures/Core/Rrd/FallbackSourceIdentity.php '^(lib/rrd\.php|tests/fixtures/Core/Rrd/FallbackSourceIdentity\.php)'
run_php_probe tests/fixtures/UserAdministrationAuditCoverage.php '^(user_admin\.php|lib/functions\.php|tests/fixtures/UserAdministrationAudit)'
run_probe tests/e2e/installer-coverage.sh '^(lib/installer\.php|cli/(install_cacti|upgrade_database)\.php|install/(functions|preflight|report|background|install)\.php|tests/e2e/installer)' 18277
@@ -0,0 +1,153 @@
"""Regression tests for missing files and coverage threshold boundaries.
Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
"""
import importlib.util
import hashlib
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
import xml.etree.ElementTree as ET
sys.dont_write_bytecode = True
SPEC = importlib.util.spec_from_file_location(
'changed_php_coverage', Path(__file__).with_name('check_changed_php_coverage.py'))
GATE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(GATE)
class ChangedPhpCoverageTest(unittest.TestCase):
def test_diff_uses_merge_base_and_ignores_deletion_only_files(self):
diff = ('diff --git a/lib/a.php b/lib/a.php\n+++ b/lib/a.php\n'
'@@ -4,0 +5,2 @@\n+return true;\n+}\n'
'diff --git a/lib/b.php b/lib/b.php\n+++ b/lib/b.php\n'
'@@ -3 +2,0 @@\n-return false;\n')
with patch.object(GATE.subprocess, 'check_output', side_effect=['merge-base\n', diff]) as command:
self.assertEqual(GATE.added_lines('base', Path('.')), {'lib/a.php': {5, 6}})
self.assertIn('merge-base', command.call_args_list[1].args[0])
def test_missing_entrypoint_fails_even_if_library_is_fully_covered(self):
result = {'covered': 100, 'executable': 100, 'files': {
'lib/a.php': {'covered': 100}, 'plugins.php': {'missing': True}}}
self.assertFalse(GATE.passes(result, 80))
def test_threshold_does_not_round_a_failure_up_to_eighty(self):
self.assertFalse(GATE.passes({'covered': 7999, 'executable': 10000, 'files': {}}, 80))
self.assertTrue(GATE.passes({'covered': 80, 'executable': 100, 'files': {}}, 80))
def test_no_executable_changes_are_not_a_missing_report(self):
self.assertTrue(GATE.passes({'covered': 0, 'executable': 0, 'files': {}}, 80))
self.assertFalse(GATE.passes({'covered': 0, 'executable': 0, 'files': {
'plugins.php': {'missing': True}}}, 80))
def test_clover_counts_only_statements_on_changed_lines(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
report = root / 'coverage.xml'
report.write_text(f'<coverage><project><file name="{root}/plugins.php">'
'<line num="3" type="method" count="1"/>'
'<line num="3" type="stmt" count="0"/>'
'<line num="4" type="stmt" count="2"/>'
'<line num="9" type="stmt" count="5"/>'
'</file></project></coverage>')
result = GATE.measure({'plugins.php': {3, 4, 5}}, report, root)
self.assertEqual(result['covered'], 1)
self.assertEqual(result['executable'], 2)
self.assertEqual(result['files']['plugins.php']['uncovered'], [3])
def test_config_keeps_tests_and_adds_entrypoints_to_source_scope(self):
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / 'input.xml'
output = Path(directory) / 'output.xml'
config.write_text('<phpunit bootstrap="tests/bootstrap-unit.php">'
'<testsuites><testsuite name="Unit"><directory>tests/Unit</directory>'
'</testsuite></testsuites><source><include><directory>lib</directory>'
'</include></source></phpunit>')
GATE.prepare_config(config, output, {'lib/plugins.php': {3}, 'plugins.php': {4}})
tree = ET.parse(output)
self.assertEqual(tree.getroot().attrib['bootstrap'], 'tests/bootstrap-unit.php')
self.assertEqual(tree.findtext('testsuites/testsuite/directory'), 'tests/Unit')
self.assertEqual([file.text for file in tree.findall('source/include/file')],
['lib/plugins.php', 'plugins.php'])
def test_request_reports_require_matching_source_and_keep_uncovered_lines(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / 'plugins.php'
source.write_text('<?php\nreturn true;\n')
report = root / 'coverage.xml'
report.write_text(f'<coverage><project><file name="{source}">'
'<line num="2" type="stmt" count="0"/>'
'<line num="3" type="stmt" count="0"/>'
'</file></project></coverage>')
requests = root / 'requests'
requests.mkdir()
snapshot = {'root': '/var/www/html', 'coverage': {
'/var/www/html/plugins.php': {'2': 1}}, 'hashes': {
'/var/www/html/plugins.php': hashlib.sha256(source.read_bytes()).hexdigest()}}
(requests / 'one.json').write_text(json.dumps(snapshot))
result = GATE.measure({'plugins.php': {2, 3}}, report, root, requests)
self.assertEqual(result['covered'], 1)
self.assertEqual(result['executable'], 2)
self.assertEqual(result['files']['plugins.php']['uncovered'], [3])
source.write_text('<?php\nreturn false;\n')
with self.assertRaisesRegex(ValueError, 'source does not match'):
GATE.measure({'plugins.php': {2, 3}}, report, root, requests)
def test_missing_request_reports_do_not_pass(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
report = root / 'coverage.xml'
report.write_text('<coverage/>')
with self.assertRaisesRegex(ValueError, 'contains no reports'):
GATE.measure({}, report, root, root)
def test_bootstrap_instructions_remain_in_the_coverage_denominator(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'include').mkdir()
source = root / 'include/global.php'
source.write_text('<?php\n$theme = "modern";\n$_SESSION["theme"] = $theme;\n')
report = root / 'coverage.xml'
report.write_text(f'<coverage><project><file name="{source}"/></project></coverage>')
result = GATE.measure({'include/global.php': {2, 3}}, report, root)
self.assertEqual(result['executable'], 2)
self.assertFalse(GATE.passes(result, 80))
requests = root / 'requests'
requests.mkdir()
snapshot = {'root': '/var/www/html', 'coverage': {
'/var/www/html/include/global.php': {'2': 1, '3': -1}}, 'hashes': {
'/var/www/html/include/global.php': hashlib.sha256(source.read_bytes()).hexdigest()}}
(requests / 'one.json').write_text(json.dumps(snapshot))
result = GATE.measure({'include/global.php': {2, 3}}, report, root, requests)
self.assertEqual(result['covered'], 1)
self.assertEqual(result['executable'], 2)
self.assertFalse(GATE.passes(result, 80))
def test_multiline_html_is_covered_only_when_its_output_opcode_runs(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / 'page.php'
source.write_text('<?php ?>\n<header>\nName\n</header><?php return;\n')
report = root / 'coverage.xml'
report.write_text(f'<coverage><project><file name="{source}">'
'<line num="2" type="stmt" count="0"/>'
'</file></project></coverage>')
requests = root / 'requests'
requests.mkdir()
snapshot = {'root': '/var/www/html', 'coverage': {
'/var/www/html/page.php': {'4': -1}}, 'hashes': {
'/var/www/html/page.php': hashlib.sha256(source.read_bytes()).hexdigest()},
'html_spans': {'/var/www/html/page.php': [[2, 4]]}}
(requests / 'one.json').write_text(json.dumps(snapshot))
self.assertEqual(GATE.measure({'page.php': {2}}, report, root, requests)['covered'], 0)
snapshot['coverage']['/var/www/html/page.php']['4'] = 1
(requests / 'one.json').write_text(json.dumps(snapshot))
self.assertEqual(GATE.measure({'page.php': {2}}, report, root, requests)['covered'], 1)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,87 @@
"""Verify request coverage dispatch using only the runner's required tools.
Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
"""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
class RequestDispatchTest(unittest.TestCase):
def test_queue_change_runs_the_queue_probe_without_ripgrep(self):
self.dispatch('cli/queue_worker.php', 'queue')
def test_html_change_runs_the_browser_probe(self):
self.dispatch('lib/html.php', 'accessibility')
def test_documentation_change_runs_no_request_probe(self):
self.dispatch('README.md', None)
def test_installer_change_runs_the_installer_probe(self):
self.dispatch('lib/installer.php', 'installer')
def test_graph_change_runs_the_native_graph_probe(self):
self.dispatch('lib/rrd.php', 'FallbackSourceIdentity.php')
def test_account_change_runs_the_native_audit_probe(self):
self.dispatch('lib/functions.php', 'UserAdministrationAuditCoverage.php')
def test_auth_change_runs_the_native_auth_probe(self):
self.dispatch('lib/auth.php', 'BoundaryContracts.php')
def dispatch(self, changed, expected):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = root / 'repo'
repository.mkdir()
tools = root / 'tools'
tools.mkdir()
for command in ['git', 'bash', 'grep', 'mkdir']:
(tools / command).symlink_to(shutil.which(command))
php = tools / 'php'
php.write_text('#!' + shutil.which('bash') + '\n'
'script="${!#}"\n'
'printf "%s\\n" "${script##*/}" >> "$CACTI_PR_RAW_COVERAGE/probes"\n')
php.chmod(0o700)
env = dict(os.environ, PATH=str(tools))
def git(*arguments):
return subprocess.check_output(
['git', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null',
'-c', 'user.name=Coverage fixture', '-c', 'user.email=fixture@example.invalid',
*arguments], cwd=repository, env=env, text=True).strip()
git('init', '--quiet')
(repository / 'README.md').write_text('fixture\n')
for name, script in [('queue', 'queue/run.sh'), ('accessibility', 'accessibility-coverage.sh'),
('installer', 'installer-coverage.sh')]:
path = repository / 'tests/e2e' / script
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('printf "%s\\n" ' + name + ' >> "$CACTI_PR_COVERAGE_OUTPUT/probes"\n')
for script in ['Core/Auth/BoundaryContracts.php', 'Core/Rrd/FallbackSourceIdentity.php',
'UserAdministrationAuditCoverage.php']:
path = repository / 'tests/fixtures' / script
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('<?php // dispatch fixture\n')
git('add', '.')
git('commit', '--quiet', '-m', 'fixture base')
base = git('rev-parse', 'HEAD')
path = repository / changed
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('changed fixture\n')
git('add', '.')
git('commit', '--quiet', '-m', 'fixture change')
output = root / 'coverage'
runner = Path(__file__).with_name('run_request_coverage.sh').resolve()
subprocess.run(['bash', str(runner)], cwd=repository, env=dict(
env, BASE_COMMIT=base, CACTI_PR_COVERAGE_OUTPUT=str(output)), check=True)
probe = output / 'probes'
self.assertEqual(probe.read_text().splitlines() if probe.exists() else [],
[expected] if expected else [])
if __name__ == '__main__':
unittest.main()
+60 -3
View File
@@ -277,19 +277,38 @@ function form_actions() : void {
if (gnrv('drp_action') == '1') { // delete if (gnrv('drp_action') == '1') { // delete
for ($i = 0; ($i < cacti_count($selected_items)); $i++) { for ($i = 0; ($i < cacti_count($selected_items)); $i++) {
if ($_SESSION[SESS_USER_ID] != $selected_items[$i]) { if ($_SESSION[SESS_USER_ID] != $selected_items[$i]) {
$affected_user = get_username($selected_items[$i]);
user_remove($selected_items[$i]); user_remove($selected_items[$i]);
if (is_string($affected_user) && $affected_user !== '' && db_fetch_row_prepared('SELECT id FROM user_auth WHERE id = ?', [$selected_items[$i]]) === []) {
log_user_action($affected_user, 'removed');
}
} else { } else {
raise_message('attempt current', __('You are not allowed to delete the current login account'), MESSAGE_LEVEL_ERROR); raise_message('attempt current', __('You are not allowed to delete the current login account'), MESSAGE_LEVEL_ERROR);
} }
} }
} elseif (gnrv('drp_action') == '3') { // enable } elseif (gnrv('drp_action') == '3') { // enable
for ($i = 0; ($i < cacti_count($selected_items)); $i++) { for ($i = 0; ($i < cacti_count($selected_items)); $i++) {
$original_user = db_fetch_row_prepared('SELECT username, enabled FROM user_auth WHERE id = ?', [$selected_items[$i]]);
user_enable($selected_items[$i]); user_enable($selected_items[$i]);
$saved_user = db_fetch_row_prepared('SELECT username, enabled FROM user_auth WHERE id = ?', [$selected_items[$i]]);
if (is_array($original_user) && $original_user !== [] && is_array($saved_user) && $saved_user !== [] &&
($original_user['enabled'] ?? '') !== 'on' && ($saved_user['enabled'] ?? '') === 'on') {
log_user_action($saved_user['username'], 'enabled');
}
} }
} elseif (gnrv('drp_action') == '4') { // disable } elseif (gnrv('drp_action') == '4') { // disable
for ($i = 0; ($i < cacti_count($selected_items)); $i++) { for ($i = 0; ($i < cacti_count($selected_items)); $i++) {
if ($_SESSION[SESS_USER_ID] != $selected_items[$i]) { if ($_SESSION[SESS_USER_ID] != $selected_items[$i]) {
$original_user = db_fetch_row_prepared('SELECT username, enabled FROM user_auth WHERE id = ?', [$selected_items[$i]]);
user_disable($selected_items[$i]); user_disable($selected_items[$i]);
$saved_user = db_fetch_row_prepared('SELECT username, enabled FROM user_auth WHERE id = ?', [$selected_items[$i]]);
if (is_array($original_user) && $original_user !== [] && is_array($saved_user) && $saved_user !== [] &&
($original_user['enabled'] ?? '') === 'on' && ($saved_user['enabled'] ?? '') === '') {
log_user_action($saved_user['username'], 'disabled');
}
} else { } else {
raise_message('attempt current', __('You are not allowed to disable the current login account'), MESSAGE_LEVEL_ERROR); raise_message('attempt current', __('You are not allowed to disable the current login account'), MESSAGE_LEVEL_ERROR);
} }
@@ -513,6 +532,11 @@ function form_save() : void {
gfrv('policy_graph_templates'); gfrv('policy_graph_templates');
// ==================================================== // ====================================================
$original_user = db_fetch_row_prepared('SELECT username, enabled, locked
FROM user_auth
WHERE id = ?',
[get_nfilter_request_var('id')]);
$old_password = db_fetch_cell_prepared('SELECT password $old_password = db_fetch_cell_prepared('SELECT password
FROM user_auth FROM user_auth
WHERE id = ?', WHERE id = ?',
@@ -595,12 +619,45 @@ function form_save() : void {
db_execute_prepared('DELETE FROM sessions WHERE user_id = ?', [$save['id']]); db_execute_prepared('DELETE FROM sessions WHERE user_id = ?', [$save['id']]);
} }
$is_new_user = empty($save['id']);
$save = api_plugin_hook_function('user_admin_setup_sql_save', $save); $save = api_plugin_hook_function('user_admin_setup_sql_save', $save);
if (!is_error_message()) { if (!is_error_message()) {
$user_id = sql_save($save, 'user_auth'); $user_id = sql_save($save, 'user_auth');
if ($user_id) { if ($user_id) {
$saved_user = db_fetch_row_prepared('SELECT id, username, password, enabled, locked FROM user_auth WHERE id = ?', [$user_id]);
// sql_save() success and the intended audit fields must both be
// confirmed before recording a completed account action.
if (!is_array($saved_user) ||
($saved_user['username'] ?? null) !== $save['username'] ||
($saved_user['password'] ?? null) !== $save['password'] ||
($saved_user['enabled'] ?? null) !== $save['enabled'] ||
($saved_user['locked'] ?? null) !== $save['locked']) {
$saved_user = [];
}
if ($saved_user !== []) {
log_user_action($saved_user['username'], $is_new_user ? 'created' : 'edited');
}
if (!$is_new_user && is_array($original_user) && $original_user !== [] && $saved_user !== []) {
$was_enabled = ($original_user['enabled'] ?? '') === 'on';
$is_enabled = ($saved_user['enabled'] ?? '') === 'on';
$was_locked = ($original_user['locked'] ?? '') === 'on';
$is_locked = ($saved_user['locked'] ?? '') === 'on';
if ($was_enabled !== $is_enabled) {
log_user_action($saved_user['username'], $is_enabled ? 'enabled' : 'disabled');
}
if ($was_locked !== $is_locked) {
log_user_action($saved_user['username'], $is_locked ? 'locked' : 'unlocked');
}
}
$reset_link_created = false; $reset_link_created = false;
if (($save['id'] == 0 && read_config_option('secnotify_newuser') == 'on') || if (($save['id'] == 0 && read_config_option('secnotify_newuser') == 'on') ||
@@ -630,8 +687,6 @@ function form_save() : void {
send_mail($save['email_address'], null, read_config_option('secnotify_newuser_subject'), $body, [], [], true); send_mail($save['email_address'], null, read_config_option('secnotify_newuser_subject'), $body, [], [], true);
} }
cacti_log(sprintf('NOTE: New user created, username %s, created by %s', $save['email_address'], get_username()), false, 'SYSTEM');
} }
if ($save['id'] > 0) { if ($save['id'] > 0) {
@@ -641,7 +696,9 @@ function form_save() : void {
send_mail($save['email_address'], null, read_config_option('secnotify_chpass_subject'), $body, [], [], true); send_mail($save['email_address'], null, read_config_option('secnotify_chpass_subject'), $body, [], [], true);
} }
cacti_log(sprintf('NOTE: Admin %s, changed password for user %s', get_username(), $save['email_address']), false, 'SYSTEM'); if (is_array($original_user) && $original_user !== [] && $saved_user !== [] && $saved_user['password'] !== $old_password) {
log_user_action($saved_user['username'], 'changed password');
}
} }
raise_message(1); raise_message(1);