diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d4407a6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.php] +indent_style = tab +indent_size = 4 + +[*.{js,sh}] +indent_style = tab +indent_size = 4 + +[*.{po,pot,mo}] +binary = true + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..a71397f --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,89 @@ +# +-------------------------------------------------------------------------+ +# | Copyright (C) 2004-2026 The Cacti Group | +# | | +# | This program is free software; you can redistribute it and/or | +# | modify it under the terms of the GNU General Public License | +# | as published by the Free Software Foundation; either version 2 | +# | of the License, or (at your option) any later version. | +# | | +# | This program is distributed in the hope that it will be useful, | +# | but WITHOUT ANY WARRANTY; without even the implied warranty of | +# | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | +# | GNU General Public License for more details. | +# +-------------------------------------------------------------------------+ +# | Cacti: The Complete RRDtool-based Graphing Solution | +# +-------------------------------------------------------------------------+ +# | This code is designed, written, and maintained by the Cacti Group. See | +# | about.php and/or the AUTHORS file for specific developer information. | +# +-------------------------------------------------------------------------+ +# | http://www.cacti.net/ | +# +-------------------------------------------------------------------------+ + +name: Code Quality + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + code-quality: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + + name: PHP ${{ matrix.php }} Code Quality + + steps: + - name: Checkout audit Plugin + uses: actions/checkout@v4 + + - name: Install PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: intl, mbstring, dom, xml, json, sockets + coverage: none + ini-values: "memory_limit=512M" + + - name: Check PHP version + run: php -v + + - name: Validate composer.json + run: composer validate --strict + + - name: Install Composer Dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + + - name: Check PHP Syntax + run: | + if find . -name '*.php' -not -path './vendor/*' -not -path './locales/*' -exec php -l {} \; 2>&1 | grep -iv 'no syntax errors detected'; then + echo "Syntax errors found!" + exit 1 + fi + + - name: Run PHP-CS-Fixer (dry-run) + run: vendor/bin/php-cs-fixer fix --dry-run --diff --config=./.php-cs-fixer.php --allow-unsupported-php-version=yes + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress + + - name: Run Pest Tests + if: ${{ matrix.php != '8.1' }} + run: vendor/bin/pest tests/Security --display-warnings + + - name: Run Audit Security Helper Tests + run: | + php tests/security_functions_test.php + php tests/controller_security_test.php + php tests/syslog_queue_test.php + php tests/auth_audit_test.php + timeout 60 php tests/syslog_functions_test.php \ No newline at end of file diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index befe032..b615982 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -40,6 +40,7 @@ jobs: matrix: php: ['8.4'] os: [ubuntu-latest] + cacti_branch: ['1.2.x', 'develop'] services: mysql: @@ -57,14 +58,14 @@ jobs: --health-timeout=5s --health-retries=3 - name: PHP ${{ matrix.php }} Integration Test on ${{ matrix.os }} + name: PHP ${{ matrix.php }} Integration Test (Cacti ${{ matrix.cacti_branch }}) on ${{ matrix.os }} steps: - name: Checkout Cacti uses: actions/checkout@v7 with: repository: Cacti/cacti - ref: 1.2.x + ref: ${{ matrix.cacti_branch }} path: cacti - name: Checkout audit Plugin @@ -195,6 +196,39 @@ 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: | @@ -211,6 +245,7 @@ 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 - name: Run Cacti Poller run: | @@ -241,6 +276,58 @@ 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 " + 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 poller.php --poller=1 --force --debug + + 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_hash = SHA2( + CONCAT('audit_ci_login', '|', 0, '|', ( + SELECT time FROM user_log + WHERE username = 'audit_ci_login' AND user_id = 0 + ORDER BY time DESC LIMIT 1 + )), + 256 + ); + ") + + 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 + - name: check audit log entries run: | cd ${{ github.workspace }} @@ -256,3 +343,23 @@ 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'); + ") + + if [ "$AUDIT_SETTING_COUNT" -ne 0 ] || [ "$AUDIT_TABLE_COUNT" -ne 0 ]; then + echo "Audit plugin uninstall left settings or tables behind" + exit 1 + fi diff --git a/.gitignore b/.gitignore index 3dd84d9..17eb625 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,16 @@ locales/po/*.mo + +# Composer +vendor/ + +# PHPStan +phpstan.cache/ +.phpstan-baseline.neon.local + +# PHP-CS-Fixer +.php-cs-fixer.cache + +# Pest / PHPUnit +.phpunit.result.cache diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..b1cccc6 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,227 @@ +exclude('locales') + ->exclude('js') + ->exclude('.github') + ->exclude('openspec') + ->exclude('vendor') + ->in(__DIR__); + +$config = new PhpCsFixer\Config(); +$config + ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) + ->setRiskyAllowed(true) + ->setIndent("\t") + ->setLineEnding("\n") + ->setRules(array( + 'header_comment' => false, + 'comment_to_phpdoc' => true, + 'phpdoc_align' => true, + 'list_syntax' => ['syntax' => 'short'], + 'array_syntax' => ['syntax' => 'short'], + 'trim_array_spaces' => false, + 'no_whitespace_before_comma_in_array' => true, + 'whitespace_after_comma_in_array' => true, + 'no_multiline_whitespace_around_double_arrow' => true, + 'no_whitespace_in_blank_line' => true, + 'no_trailing_whitespace' => true, + 'normalize_index_brace' => true, + 'no_mixed_echo_print' => ['use' => 'print'], + 'no_spaces_after_function_name' => true, + 'braces' => [ + 'position_after_functions_and_oop_constructs' => 'same', + 'position_after_control_structures' => 'same', + 'allow_single_line_closure' => true + ], + 'braces_position' => [ + 'anonymous_classes_opening_brace' => 'same_line', + 'anonymous_functions_opening_brace' => 'same_line', + 'classes_opening_brace' => 'same_line', + 'functions_opening_brace' => 'same_line' + ], + 'single_blank_line_at_eof' => true, + 'method_chaining_indentation' => true, + 'indentation_type' => true, + 'constant_case' => true, + 'lowercase_keywords' => true, + 'line_ending' => true, + 'magic_constant_casing' => true, + 'native_function_casing' => true, + 'elseif' => true, + 'include' => false, + 'no_alternative_syntax' => true, + 'no_superfluous_elseif' => true, + 'no_trailing_comma_in_singleline' => true, + 'no_unneeded_braces' => true, + 'no_useless_else' => false, + 'yoda_style' => [ + 'equal' => false, + 'identical' => false, + 'less_and_greater' => null, + 'always_move_variable' => false + ], + 'declare_equal_normalize' => ['space' => 'single'], + 'dir_constant' => true, + 'single_space_around_construct' => [ + 'constructs_followed_by_a_single_space' => [ + 'abstract', + 'as', + 'attribute', + 'break', + 'case', + 'catch', + 'class', + 'clone', + 'const', + 'const_import', + 'continue', + 'do', + 'echo', + 'else', + 'elseif', + 'extends', + 'final', + 'finally', + 'for', + 'foreach', + 'function', + 'function_import', + 'global', + 'goto', + 'if', + 'implements', + 'instanceof', + 'insteadof', + 'interface', + 'match', + 'named_argument', + 'new', + 'open_tag_with_echo', + 'php_open', + 'print', + 'private', + 'protected', + 'public', + 'return', + 'static', + 'throw', + 'trait', + 'try', + 'use', + 'use_lambda', + 'use_trait', + 'var', + 'while', + 'yield', + 'yield_from' + ] + ], + 'concat_space' => ['spacing' => 'one'], + 'switch_case_semicolon_to_colon' => true, + 'switch_case_space' => true, + 'switch_continue_to_break' => true, + 'logical_operators' => true, + 'function_declaration' => ['closure_function_spacing' => 'one'], + 'spaces_inside_parentheses' => true, + 'binary_operator_spaces' => [ + 'operators' => [ + '+=' => 'align_single_space', + '===' => 'align_single_space_minimal', + '=' => 'align_single_space', + '|' => 'single_space', + '=>' => 'align', + '!=' => 'align' + ] + ], + 'not_operator_with_space' => false, + 'no_spaces_around_offset' => ['positions' => ['outside', 'inside']], + 'standardize_not_equals' => true, + 'ternary_operator_spaces' => true, + 'full_opening_tag' => false, + 'linebreak_after_opening_tag' => false, + 'phpdoc_add_missing_param_annotation' => true, + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'break', + 'case', + 'continue', + 'curly_brace_block', + 'default', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'switch', + 'throw', + 'use' + ] + ], + 'no_empty_statement' => true, + 'multiline_whitespace_before_semicolons' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'semicolon_after_instruction' => false, + 'space_after_semicolon' => ['remove_in_empty_for_expressions' => true], + 'blank_line_before_statement' => [ + 'statements' => [ + 'continue', + 'break', + 'declare', + 'do', + 'for', + 'foreach', + 'goto', + 'if', + 'return', + 'switch', + 'throw', + 'try', + 'while', + 'yield', + 'yield_from' + ] + ], + 'explicit_string_variable' => false, + 'single_quote' => true, + 'string_line_ending' => true, + 'strict_param' => true, + 'align_multiline_comment' => ['comment_type' => 'phpdocs_like'], + 'single_line_comment_spacing' => true, + 'single_line_comment_style' => true, + 'multiline_comment_opening_closing' => true + )) + ->setFinder($finder); + +return $config; \ No newline at end of file diff --git a/.phpstan.neon b/.phpstan.neon new file mode 100644 index 0000000..6087a8c --- /dev/null +++ b/.phpstan.neon @@ -0,0 +1,47 @@ +includes: + - phpstan-baseline.neon + +parameters: + parallel: + maximumNumberOfProcesses: 8 + paths: + - setup.php + - audit.php + - audit_functions.php + - audit_syslog.php + - index.php + - locales/index.php + - locales/LC_MESSAGES/index.php + - tests/ + excludePaths: + analyse: + - locales/* + - js/* + - vendor/* + - '*.mo' + - '*.po' + - '*.pot' + scanFiles: + - phpstan/stubs/cacti.stubs.php + level: 8 + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + ignoreErrors: + - identifier: missingType.iterableValue + - identifier: if.alwaysFalse + - identifier: notEqual.alwaysFalse + - identifier: notEqual.alwaysTrue + - identifier: equal.alwaysTrue + - identifier: if.alwaysTrue + - identifier: while.alwaysTrue + - identifier: greater.alwaysFalse + - identifier: identical.alwaysTrue + - identifier: identical.alwaysFalse + - identifier: booleanAnd.leftAlwaysTrue + - identifier: booleanAnd.leftAlwaysFalse + - identifier: function.alreadyNarrowedType + - identifier: includeOnce.fileNotFound + - identifier: booleanAnd.rightAlwaysFalse + - identifier: booleanAnd.rightAlwaysTrue + - identifier: property.notFound + - identifier: parameterByRef.unusedType \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d56164f..366a7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ --- develop --- +* 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 anti-join paging and transactionally durable database-backed deduplication via audit_user_log_state +* feature: Apply the audit retention cutoff to every ingestion batch so historical rows are not replayed +* feature: Detect brute-force login patterns every poller cycle with atomically throttled critical alerts across concurrent pollers +* feature: Capture authorization-denied events through Cacti's custom_denied hook without taking over the denied-page rendering, with referer 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: Persist authentication defaults on install and upgrade without overwriting existing administrator choices + * 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 diff --git a/README.md b/README.md index cbffe64..be2003f 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,52 @@ 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. + +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 in an `audit_user_log_state` table as a deterministic SHA-256 hash, +so repeated and concurrent pollers cannot double-record the same source row. +Each bounded query selects recent source rows without a durable marker, so +failed inserts and late commits remain discoverable. The audit row and state +marker are committed in one transaction before external delivery; a +concurrent loser rolls back its duplicate audit row. State markers survive +audit-log purges and are retired only after their source rows fall outside +the configured retention window. + +Every ingestion query applies the audit retention cutoff +(`NOW() - audit_retention` days, or 90 days if retention is indefinite), so +arbitrary historical `user_log` rows are not replayed and stale records are +not exported. + +Brute-force detection runs every poller cycle and emits a +`cacti.auth.brute_force_suspected` critical event when failed logins exceed a +configurable threshold within a rolling window. 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. Authentication auditing and +brute-force detection settings are restricted to Audit Log Admin users. +Database-level changes and API activity remain outside the current Cacti 1.2.x +scope. ## Permissions diff --git a/audit.php b/audit.php index 8703ec0..f3d83b7 100644 --- a/audit.php +++ b/audit.php @@ -28,130 +28,134 @@ set_default_action(); switch(get_request_var('action')) { -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; - } + case 'export': + audit_export_rows(); - $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'); + break; + case 'syslog_test': + if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + header('Allow: POST'); + exit; + } - top_header(); - audit_log(); - bottom_footer(); + if (!audit_user_is_admin() || !csrf_check(false)) { + http_response_code(403); + exit; + } - break; -case 'syslog_retry': - if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(405); - header('Allow: POST'); - exit; - } + $result = audit_syslog_test_delivery(); + $success = $result['status'] === 'sent_unconfirmed'; + audit_record_event('audit.syslog.test', [ + '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' => [ + '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(); - if (!audit_user_is_admin() || !csrf_check(false)) { - http_response_code(403); - exit; - } + break; + case 'syslog_retry': + if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + header('Allow: POST'); + 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'); + if (!audit_user_is_admin() || !csrf_check(false)) { + http_response_code(403); + exit; + } - top_header(); - audit_log(); - bottom_footer(); + $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW); + $post = is_array($post) ? $post : []; + $delivery_ids = isset($post['delivery_ids']) && is_array($post['delivery_ids']) + ? $post['delivery_ids'] + : []; + $retried = audit_syslog_retry_dead_letters($delivery_ids); + audit_record_event('audit.syslog.dead_letter.retried', [ + 'event_category' => 'audit', + 'severity' => 'notice', + 'action' => 'retry', + 'target_type' => 'syslog_delivery', + 'operation_outcome' => 'success', + 'details' => ['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') { - http_response_code(405); - header('Allow: POST'); - exit; - } + break; + case 'purge': + 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; - } + if (!audit_user_is_admin() || !csrf_check(false)) { + http_response_code(403); + exit; + } - audit_purge(); + audit_purge(); - top_header(); - audit_log(); - bottom_footer(); + top_header(); + audit_log(); + bottom_footer(); - break; -case 'getdata': - $data = db_fetch_row_prepared('SELECT * + break; + case 'getdata': + $data = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', - array(get_filter_request_var('id'))); + [get_filter_request_var('id')]); - if (!cacti_sizeof($data)) { - http_response_code(404); - print html_escape(__('Audit event not found.', 'audit')); - break; - } + if (!is_array($data) || $data === []) { + http_response_code(404); + print html_escape(__('Audit event not found.', 'audit')); - audit_record_event('audit.event.viewed', array( - 'event_category' => 'audit', - 'target_type' => 'audit_event', - 'target_id' => $data['event_uuid'] != '' ? $data['event_uuid'] : $data['id'], - 'details' => array('record_id' => $data['id']) - )); - - $output = audit_render_event_details($data); - echo $output; - - break; -default: - top_header(); - audit_log(); - bottom_footer(); + break; + } + + audit_record_event('audit.event.viewed', [ + 'event_category' => 'audit', + 'target_type' => 'audit_event', + 'target_id' => $data['event_uuid'] != '' ? $data['event_uuid'] : $data['id'], + 'details' => ['record_id' => $data['id']] + ]); + + $output = audit_render_event_details($data); + print $output; + + break; + default: + top_header(); + audit_log(); + bottom_footer(); } -function audit_render_event_details($data) { +/** + * @param array $data + */ +function audit_render_event_details(array $data): string { $width = 'wide'; $output = '
'; $output .= '' . __('Page:', 'audit') . ' ' . html_escape($data['page']) . ''; @@ -163,30 +167,39 @@ function audit_render_event_details($data) { $output .= '
' . __('Event ID:', 'audit') . ' ' . html_escape($data['event_uuid']) . ''; $output .= '
' . __('Request Status:', 'audit') . ' ' . html_escape($data['request_status']) . ''; $output .= '
' . __('Operation Outcome:', 'audit') . ' ' . html_escape($data['operation_outcome']) . ''; + if ($data['outcome_reason'] != '') { $output .= '
' . __('Outcome Reason:', 'audit') . ' ' . html_escape($data['outcome_reason']) . ''; } $output .= '
' . __('External File Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . ''; + if ($data['external_error'] != '') { $output .= '
' . __('External File Error:', 'audit') . ' ' . html_escape($data['external_error']) . ''; } - if (db_table_exists('audit_syslog_delivery')) { + if (audit_syslog_enabled() && 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'])); + [$data['id']]); - if (cacti_sizeof($syslog)) { - $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($syslog['state']) . ''; - $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . (int) $syslog['attempts'] . ''; - if ($syslog['sent_time'] != '') { - $output .= '
' . __('Syslog Socket Write:', 'audit') . ' ' . html_escape($syslog['sent_time']) . ''; + if (is_array($syslog) && cacti_sizeof($syslog) > 0) { + $state = (string) ($syslog['state'] ?? 'unknown'); + $attempts = (int) ($syslog['attempts'] ?? 0); + $sent_time = (string) ($syslog['sent_time'] ?? ''); + $last_error = (string) ($syslog['last_error'] ?? ''); + + $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($state) . ''; + $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . $attempts . ''; + + if ($sent_time != '') { + $output .= '
' . __('Syslog Socket Write:', 'audit') . ' ' . html_escape($sent_time) . ''; } - if ($syslog['last_error'] != '') { - $output .= '
' . __('Syslog Error:', 'audit') . ' ' . html_escape($syslog['last_error']) . ''; + + if ($last_error != '') { + $output .= '
' . __('Syslog Error:', 'audit') . ' ' . html_escape($last_error) . ''; } } } @@ -194,13 +207,14 @@ function audit_render_event_details($data) { if ($data['action'] == 'cli') { $output .= '' . __('Script:', 'audit') . ' ' . html_escape($data['post']) . ''; + return $output . '
'; } $attribs = audit_json_decode($data['post'], $json_error); - $attribs = is_array($attribs) ? $attribs : array( + $attribs = is_array($attribs) ? $attribs : [ __('Stored Data', 'audit') => __('The stored request data is not valid JSON: %s', $json_error, 'audit') - ); + ]; ksort($attribs); $columns = cacti_sizeof($attribs) > 16 ? 2 : 1; @@ -210,6 +224,7 @@ function audit_render_event_details($data) { : '' . __('Attrib', 'audit') . '' . __('Value', 'audit') . ''; $i = 0; + foreach ($attribs as $field => $content) { if ($i % $columns == 0) { $output .= ''; @@ -228,6 +243,7 @@ function audit_render_event_details($data) { } $record_data = audit_json_decode($data['object_data'], $object_error); + if (is_array($record_data) && !empty($record_data)) { $output .= '
'; $output .= '' . __('Record Data:', 'audit') . ''; @@ -240,7 +256,7 @@ function audit_render_event_details($data) { return $output . ''; } -function audit_render_value($value) { +function audit_render_value(mixed $value): string { if (is_array($value) || is_object($value)) { return '
' . html_escape(json_encode($value, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE)) . '
'; } @@ -254,7 +270,7 @@ function audit_render_value($value) { return html_escape((string) $value); } -function audit_purge() { +function audit_purge(): void { $protected = db_fetch_cell("SELECT COUNT(*) FROM audit_log WHERE EXISTS ( @@ -273,16 +289,16 @@ function audit_purge() { )"); $purged = db_affected_rows(); - audit_record_event('audit.log.purged', array( + audit_record_event('audit.log.purged', [ 'event_category' => 'audit', - 'severity' => 'warning', - 'action' => 'purge', - 'target_type' => 'audit_log', - 'details' => array( - 'purged_count' => $purged, + 'severity' => 'warning', + 'action' => 'purge', + 'target_type' => 'audit_log', + 'details' => [ + 'purged_count' => $purged, 'protected_count' => (int) $protected - ) - )); + ] + ]); $_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'); @@ -292,12 +308,12 @@ function audit_purge() { raise_message('audit_message'); } -function audit_export_rows() { +function audit_export_rows(): void { audit_process_request_vars(); - /* form the 'where' clause for our main sql query */ - $sql_clauses = array(); - $sql_params = array(); + // form the 'where' clause for our main sql query + $sql_clauses = []; + $sql_params = []; if (get_request_var('filter') != '') { $sql_clauses[] = '(page LIKE ? OR post LIKE ?)'; @@ -324,17 +340,17 @@ function audit_export_rows() { $sql_where", $sql_params); - audit_record_event('audit.log.exported', array( + audit_record_event('audit.log.exported', [ 'event_category' => 'audit', - 'action' => 'export', - 'target_type' => 'audit_log', - 'details' => array( - 'row_count' => cacti_sizeof($events), - 'filter' => get_request_var('filter'), + 'action' => 'export', + 'target_type' => 'audit_log', + 'details' => [ + 'row_count' => cacti_sizeof($events), + 'filter' => get_request_var('filter'), 'event_page' => get_request_var('event_page'), - 'user_id' => get_request_var('user_id') - ) - )); + 'user_id' => get_request_var('user_id') + ] + ]); if (cacti_sizeof($events)) { header('Content-Disposition: attachment; filename=audit_export.csv'); @@ -342,111 +358,116 @@ function audit_export_rows() { header('X-Content-Type-Options: nosniff'); $output = fopen('php://output', 'w'); - fputcsv($output, array('event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'page', 'user_id', 'username', 'action', 'request_status', 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', 'external_status', 'external_error', 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', 'completed_time', 'duration_ms', 'integrity_hash', 'post', 'details'), ',', '"', ''); - - foreach($events as $event) { - if ($event['action'] == 'cli') { - $poster = $event['post']; - } else { - $post = audit_json_decode($event['post'], $json_error); - $poster = is_array($post) ? json_encode($post, JSON_INVALID_UTF8_SUBSTITUTE) : $event['post']; + + if ($output !== false) { + fputcsv($output, ['event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'page', 'user_id', 'username', 'action', 'request_status', 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', 'external_status', 'external_error', 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', 'completed_time', 'duration_ms', 'integrity_hash', 'post', 'details'], ',', '"', ''); + + if (is_array($events)) { + foreach ($events as $event) { + if ($event['action'] == 'cli') { + $poster = $event['post']; + } else { + $post = audit_json_decode($event['post'], $json_error); + $poster = is_array($post) ? json_encode($post, JSON_INVALID_UTF8_SUBSTITUTE) : $event['post']; + } + + fputcsv($output, array_map('audit_csv_safe_cell', [ + $event['event_uuid'], + $event['correlation_id'], + $event['event_type'], + $event['event_category'], + $event['severity'], + $event['page'], + $event['user_id'], + get_username($event['user_id']), + $event['action'], + $event['request_status'], + $event['operation_outcome'], + $event['outcome_reason'], + $event['target_type'], + $event['target_id'], + $event['external_status'], + $event['external_error'], + $event['ip_address'], + $event['user_agent'], + $event['http_method'], + $event['http_status'], + $event['event_time'], + $event['completed_time'], + $event['duration_ms'], + $event['integrity_hash'], + $poster, + $event['details'] + ]), ',', '"', ''); + } } - fputcsv($output, array_map('audit_csv_safe_cell', array( - $event['event_uuid'], - $event['correlation_id'], - $event['event_type'], - $event['event_category'], - $event['severity'], - $event['page'], - $event['user_id'], - get_username($event['user_id']), - $event['action'], - $event['request_status'], - $event['operation_outcome'], - $event['outcome_reason'], - $event['target_type'], - $event['target_id'], - $event['external_status'], - $event['external_error'], - $event['ip_address'], - $event['user_agent'], - $event['http_method'], - $event['http_status'], - $event['event_time'], - $event['completed_time'], - $event['duration_ms'], - $event['integrity_hash'], - $poster, - $event['details'] - )), ',', '"', ''); + fclose($output); } - - fclose($output); } } -function audit_process_request_vars() { - /* ================= input validation and session storage ================= */ - $filters = array( - 'rows' => array( - 'filter' => FILTER_VALIDATE_INT, +function audit_process_request_vars(): void { + // ================= input validation and session storage ================= + $filters = [ + 'rows' => [ + 'filter' => FILTER_VALIDATE_INT, 'pageset' => true, 'default' => '-1' - ), - 'page' => array( - 'filter' => FILTER_VALIDATE_INT, + ], + 'page' => [ + 'filter' => FILTER_VALIDATE_INT, 'default' => '1' - ), - 'filter' => array( - 'filter' => FILTER_DEFAULT, + ], + 'filter' => [ + 'filter' => FILTER_DEFAULT, 'pageset' => true, 'default' => '' - ), - 'sort_column' => array( - 'filter' => FILTER_CALLBACK, + ], + 'sort_column' => [ + 'filter' => FILTER_CALLBACK, 'default' => 'event_time', - 'options' => array('options' => 'sanitize_search_string') - ), - 'sort_direction' => array( - 'filter' => FILTER_CALLBACK, + 'options' => ['options' => 'sanitize_search_string'] + ], + 'sort_direction' => [ + 'filter' => FILTER_CALLBACK, 'default' => 'DESC', - 'options' => array('options' => 'sanitize_search_string') - ), - 'user_id' => array( - 'filter' => FILTER_VALIDATE_INT, + 'options' => ['options' => 'sanitize_search_string'] + ], + 'user_id' => [ + 'filter' => FILTER_VALIDATE_INT, 'pageset' => true, 'default' => '-1' - ), - 'event_page' => array( - 'filter' => FILTER_CALLBACK, + ], + 'event_page' => [ + 'filter' => FILTER_CALLBACK, 'pageset' => true, 'default' => '-1', - 'options' => array('options' => 'sanitize_search_string') - ) - ); + 'options' => ['options' => 'sanitize_search_string'] + ] + ]; validate_store_request_vars($filters, 'sess_audit'); - /* ================= input validation ================= */ + // ================= input validation ================= } -function audit_log() { +function audit_log(): void { global $item_rows; audit_process_request_vars(); $has_filters = get_request_var('filter') != '' || - get_request_var('event_page') != '-1' || + get_request_var('event_page') != '-1' || (!isempty_request_var('user_id') && get_request_var('user_id') > '-1'); - audit_record_event($has_filters ? 'audit.log.searched' : 'audit.log.viewed', array( + audit_record_event($has_filters ? 'audit.log.searched' : 'audit.log.viewed', [ 'event_category' => 'audit', - 'action' => $has_filters ? 'search' : 'view', - 'target_type' => 'audit_log', - 'details' => array( - 'filter' => get_request_var('filter'), + 'action' => $has_filters ? 'search' : 'view', + 'target_type' => 'audit_log', + 'details' => [ + 'filter' => get_request_var('filter'), 'event_page' => get_request_var('event_page'), - 'user_id' => get_request_var('user_id') - ) - )); + 'user_id' => get_request_var('user_id') + ] + ]); if (get_request_var('rows') == '-1') { $rows = read_config_option('num_rows_table'); @@ -467,73 +488,89 @@ function audit_log() {
- + - '> + '> - + - + - + - - - + + + - +
- '> + '> @@ -541,9 +578,9 @@ function audit_log() { html_end_box(); - /* form the 'where' clause for our main sql query */ - $sql_clauses = array(); - $sql_params = array(); + // form the 'where' clause for our main sql query + $sql_clauses = []; + $sql_params = []; if (get_request_var('filter') != '') { $sql_clauses[] = '(page LIKE ? OR post LIKE ?)'; @@ -572,7 +609,7 @@ function audit_log() { $sql_params); $sql_order = get_order_string(); - $sql_limit = ' LIMIT ' . ($rows*(get_request_var('page')-1)) . ',' . $rows; + $sql_limit = ' LIMIT ' . ($rows * (get_request_var('page') - 1)) . ',' . $rows; $events = db_fetch_assoc_prepared("SELECT audit_log.*, user_auth.username FROM audit_log @@ -589,61 +626,63 @@ function audit_log() { html_start_box('', '100%', '', '3', 'center', ''); - $display_text = array( - 'page' => array( + $display_text = [ + 'page' => [ 'display' => __('Page Name', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('The page where the event was generated.', 'audit') - ), - 'username' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('The page where the event was generated.', 'audit') + ], + 'username' => [ 'display' => __('User Name', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('The user who generated the event.', 'audit') - ), - 'action' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('The user who generated the event.', 'audit') + ], + 'action' => [ 'display' => __('Requested Action', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('The requested Cacti action. Hover over the action to see request data.', 'audit') - ), - 'request_status' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('The requested Cacti action. Hover over the action to see request data.', 'audit') + ], + 'request_status' => [ 'display' => __('Request Status', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('Request processing state; completion does not guarantee that every operation succeeded.', 'audit') - ), - 'external_status' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('Request processing state; completion does not guarantee that every operation succeeded.', 'audit') + ], + 'external_status' => [ 'display' => __('External File Delivery', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('Delivery state for the optional external audit log file. Remote Syslog health is shown separately.', 'audit') - ), - 'user_agent' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('Delivery state for the optional external audit log file. Remote Syslog health is shown separately.', 'audit') + ], + 'user_agent' => [ 'display' => __('User Agent', 'audit'), - 'align' => 'left', - 'sort' => 'ASC', - 'tip' => __('The browser type of the requester.', 'audit') - ), - 'ip_address' => array( + 'align' => 'left', + 'sort' => 'ASC', + 'tip' => __('The browser type of the requester.', 'audit') + ], + 'ip_address' => [ 'display' => __('IP Address', 'audit'), - 'align' => 'right', - 'sort' => 'ASC', - 'tip' => __('The IP Address of the requester.', 'audit') - ), - 'event_time' => array( + 'align' => 'right', + 'sort' => 'ASC', + 'tip' => __('The IP Address of the requester.', 'audit') + ], + 'event_time' => [ 'display' => __('Event Time', 'audit'), - 'align' => 'right', - 'sort' => 'DESC', - 'tip' => __('The time the Event took place.', 'audit') - ) - ); + 'align' => 'right', + 'sort' => 'DESC', + 'tip' => __('The time the Event took place.', 'audit') + ] + ]; html_header_sort($display_text, get_request_var('sort_column'), get_request_var('sort_direction'), false); $i = 0; + if (cacti_sizeof($events)) { + if (is_array($events)) { foreach ($events as $e) { if ($e['action'] == 'cli') { form_alternate_row('line' . $e['id'], false); @@ -666,11 +705,12 @@ function audit_log() { form_selectable_ecell($e['user_agent'], $e['id']); form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); - form_end_row(); + form_end_row(); + } } } } else { - print "" . __('No Audit Log Events Found', 'audit') . "\n"; + print "" . __('No Audit Log Events Found', 'audit') . "\n"; } html_end_box(false); @@ -684,11 +724,14 @@ function audit_log() { = $config['dead_letter_warning'] || $health['oldest_pending_seconds'] >= $config['pending_age_warning'] @@ -698,7 +741,7 @@ function audit_render_syslog_health() { print ""; print '' . __('Status', 'audit') . ''; - print '' . html_escape(!$enabled ? __('Disabled', 'audit') : ($unhealthy ? __('Unhealthy', 'audit') : __('Healthy', 'audit'))) . ''; + print '' . html_escape($unhealthy ? __('Unhealthy', 'audit') : __('Healthy', 'audit')) . ''; print '' . __('Pending', 'audit') . '' . (int) $health['pending'] . ''; print '' . __('Retry', 'audit') . '' . (int) $health['retry'] . ''; print '' . __('Dead-letter', 'audit') . '' . (int) $health['dead_letter'] . ''; @@ -711,13 +754,14 @@ function audit_render_syslog_health() { print '' . __('Last Socket Write', 'audit') . '' . html_escape($health['last_sent'] ?? __('Never', 'audit')) . ''; print ''; print " '; + if ($health['dead_letter'] > 0) { print "'; } print ''; - if ($enabled && !$config['valid']) { + if (!$config['valid']) { print "" . __esc('Configuration Error:', 'audit') . ' ' . html_escape(implode(', ', $config['errors'])) . ''; } elseif ($health['last_error'] !== null) { diff --git a/audit_functions.php b/audit_functions.php index 90e7eef..e871c81 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -1,125 +1,138 @@ $selected_items + */ +function audit_process_page_data(string $page, mixed $drop_action, array $selected_items): string { + $objects = []; + if ($drop_action !== false) { switch ($page) { case 'host.php': - //loop over array and perform query for each item + // loop over array and perform query for each item foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('SELECT id AS host_id,site_id,description,hostname,status,status_fail_date AS last_failed_date,status_rec_date AS last_recovered_date FROM host WHERE id IN (?)', - array($item)); - } + [$item]); + } + break; case 'host_templates.php': foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('SELECT name FROM host_template WHERE id IN (?)', - array($item)); + [$item]); } - break; - case 'templates_export.php': - foreach ($selected_items as $item) { - $objects[] = db_fetch_assoc_prepared('SELECT name FROM graph_templates + break; + case 'templates_export.php': + foreach ($selected_items as $item) { + $objects[] = db_fetch_assoc_prepared('SELECT name FROM graph_templates WHERE id IN (?)', - array($item)); - } - break; - + [$item]); + } - case 'automation_devices.php': - foreach ($selected_items as $item) { - $result = db_fetch_assoc_prepared('SELECT id, network_id,hostname,ip,sysName,syslocation,snmp,up + break; + case 'automation_devices.php': + foreach ($selected_items as $item) { + $result = db_fetch_assoc_prepared('SELECT id, network_id,hostname,ip,sysName,syslocation,snmp,up FROM automation_devices WHERE id IN (?)', - array($item)); + [$item]); + if (is_array($result)) { foreach ($result as &$row) { $row['snmp'] = ($row['snmp'] == 1) ? 'UP' : 'Down'; - $row['up'] = ($row['up'] == 1) ? 'Yes' : 'No'; + $row['up'] = ($row['up'] == 1) ? 'Yes' : 'No'; } - - $objects[] = $result; } - break; + $objects[] = $result; + } + break; case 'graph_templates.php': foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('SELECT name FROM graph_templates WHERE id IN (?)', - array($item)); + [$item]); } - break; + break; case 'thold.php': foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('SELECT id,name_cache AS THOLD_NAME,data_source_name AS Data_Source FROM thold_data WHERE id IN (?)', - array($item)); + [$item]); } + break; case 'data_sources.php': foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('select name_cache AS Data_Source_Name,active from data_template_data WHERE local_data_id IN (?)', - array($item)); + [$item]); } - break; + break; case 'data_templates.php': foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('SELECT name FROM data_template WHERE id IN (?)', - array($item)); + [$item]); } - break; + break; case 'aggregate_templates.php': foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('SELECT name FROM aggregate_graph_template WHERE id IN (?)', - array($item)); + [$item]); } - break; + break; case 'thold_templates.php': foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('SELECT name FROM thold_template WHERE id IN (?)', - array($item)); + [$item]); } + break; case 'user_admin.php': foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('SELECT username FROM user_auth WHERE id IN (?)', - array($item)); + [$item]); } + break; case 'user_group_admin.php': foreach ($selected_items as $item) { $objects[] = db_fetch_assoc_prepared('SELECT name FROM user_auth_group WHERE id IN (?)', - array($item)); + [$item]); } + break; } } @@ -127,16 +140,16 @@ function audit_process_page_data($page, $drop_action, $selected_items) { return audit_json_encode($objects); } -function audit_is_sensitive_key($key) { +function audit_is_sensitive_key(mixed $key): int|false { return preg_match('/(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)/i', (string) $key); } -function audit_redact_sensitive_data($data) { +function audit_redact_sensitive_data(mixed $data): mixed { if (!is_array($data)) { return $data; } - $redacted = array(); + $redacted = []; foreach ($data as $key => $value) { if (audit_is_sensitive_key($key)) { @@ -151,7 +164,7 @@ function audit_redact_sensitive_data($data) { return $redacted; } -function audit_redact_sensitive_value($value) { +function audit_redact_sensitive_value(mixed $value): mixed { if (!is_string($value)) { return $value; } @@ -165,9 +178,9 @@ function audit_redact_sensitive_value($value) { return preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $value); } -function audit_bound_log_data($data, $depth = 0, $state = null) { +function audit_bound_log_data(mixed $data, int $depth = 0, ?object $state = null): mixed { if ($state === null) { - $state = (object) array('fields' => 0); + $state = (object) ['fields' => 0]; } if ($depth >= 12) { @@ -175,11 +188,13 @@ function audit_bound_log_data($data, $depth = 0, $state = null) { } if (is_array($data)) { - $bounded = array(); + $bounded = []; foreach ($data as $key => $value) { + // @phpstan-ignore-next-line (property.notFound: dynamic property on stdClass state object) if ($state->fields >= 1000) { $bounded['audit_truncated'] = 'Additional fields were omitted.'; + break; } @@ -197,66 +212,76 @@ function audit_bound_log_data($data, $depth = 0, $state = null) { return $data; } -function audit_redact_cli_arguments($arguments) { - $redacted = array(); +/** + * @param array $arguments + * @return array + */ +function audit_redact_cli_arguments(array $arguments): array { + $redacted = []; $redact_next = false; foreach ($arguments as $argument) { if ($redact_next) { - $redacted[] = '[REDACTED]'; + $redacted[] = '[REDACTED]'; $redact_next = false; + continue; } if (preg_match('/^(--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)[^=]*)=(.*)$/i', $argument, $matches)) { $redacted[] = $matches[1] . '=[REDACTED]'; + continue; } if (preg_match('/^--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)/i', $argument)) { - $redacted[] = $argument; + $redacted[] = $argument; $redact_next = true; + continue; } - $redacted[] = preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $argument); + $redacted[] = preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $argument) ?? $argument; } return $redacted; } -function audit_json_encode($data, $options = 0) { +function audit_json_encode(mixed $data, int $options = 0): string { $json = json_encode(audit_bound_log_data($data), JSON_INVALID_UTF8_SUBSTITUTE | $options, 16); if ($json === false) { - return json_encode(array('audit_encoding_error' => json_last_error_msg())); + $fallback = json_encode(['audit_encoding_error' => json_last_error_msg()]); + + return $fallback !== false ? $fallback : '{}'; } return $json; } -function audit_json_decode($json, &$error = null) { +function audit_json_decode(mixed $json, ?string &$error = null): mixed { $error = null; try { return json_decode($json, true, 16, JSON_THROW_ON_ERROR); } catch (Throwable $exception) { $error = $exception->getMessage(); + return null; } } -function audit_uuid_v4() { - $bytes = random_bytes(16); +function audit_uuid_v4(): string { + $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); - $hex = bin2hex($bytes); + $hex = bin2hex($bytes); return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20); } -function audit_request_correlation_id() { +function audit_request_correlation_id(): string { static $correlation_id; if ($correlation_id === null) { @@ -266,7 +291,7 @@ function audit_request_correlation_id() { return $correlation_id; } -function audit_utc_time($microtime = null) { +function audit_utc_time(?float $microtime = null): string { $microtime = $microtime === null ? microtime(true) : $microtime; $seconds = (int) $microtime; $micros = (int) round(($microtime - $seconds) * 1000000); @@ -279,8 +304,11 @@ function audit_utc_time($microtime = null) { return gmdate('Y-m-d H:i:s', $seconds) . '.' . sprintf('%06d', $micros); } -function audit_event_integrity_hash($event) { - $material = array( +/** + * @param array $event + */ +function audit_event_integrity_hash(array $event): string { + $material = [ 'event_uuid' => $event['event_uuid'] ?? '', 'correlation_id' => $event['correlation_id'] ?? '', 'event_type' => $event['event_type'] ?? '', @@ -291,31 +319,35 @@ function audit_event_integrity_hash($event) { 'target_type' => $event['target_type'] ?? '', 'target_id' => $event['target_id'] ?? '', 'details' => $event['details'] ?? '' - ); + ]; return hash('sha256', audit_json_encode($material, JSON_UNESCAPED_SLASHES)); } -function audit_event_type_for_request($page, $action) { +function audit_event_type_for_request(mixed $page, mixed $action): string { $page_name = preg_replace('/\.php$/', '', (string) $page); - $page_name = preg_replace('/[^a-z0-9_]+/i', '_', $page_name); + $page_name = preg_replace('/[^a-z0-9_]+/i', '_', $page_name ?? ''); $verb = preg_replace('/[^a-z0-9_]+/i', '_', strtolower((string) $action)); - $verb = trim($verb, '_'); + $verb = trim($verb ?? '', '_'); return 'cacti.' . ($page_name !== '' ? $page_name : 'request') . '.' . ($verb !== '' && $verb !== 'none' ? $verb : 'submitted'); } -function audit_external_event_data($event) { - $fields = array( +/** + * @param array $event + * @return array + */ +function audit_external_event_data(array $event): array { + $fields = [ 'id', 'event_uuid', 'correlation_id', 'event_type', 'event_category', 'severity', 'actor_type', 'page', 'user_id', 'action', 'request_status', 'operation_outcome', 'outcome_reason', 'target_type', 'target_id', 'ip_address', 'user_agent', 'http_method', 'http_status', 'event_time', 'completed_time', 'duration_ms', 'post', 'object_data', 'details', 'previous_hash', 'integrity_hash' - ); - $data = array(); + ]; + $data = []; foreach ($fields as $field) { $data[$field] = $event[$field] ?? null; @@ -324,9 +356,12 @@ function audit_external_event_data($event) { return $data; } -function audit_external_log_format($data, $format = 'json') { +/** + * @param array $data + */ +function audit_external_log_format(array $data, string $format = 'json'): string { if ($format === 'text') { - $fields = array(); + $fields = []; foreach ($data as $name => $value) { if (is_array($value) || is_object($value)) { @@ -338,8 +373,8 @@ function audit_external_log_format($data, $format = 'json') { } $value = str_replace( - array('\\', "\r", "\n", "\t", '"'), - array('\\\\', '\r', '\n', '\t', '\"'), + ['\\', "\r", "\n", "\t", '"'], + ['\\\\', '\r', '\n', '\t', '\"'], (string) $value ); $fields[] = $name . '="' . $value . '"'; @@ -348,7 +383,7 @@ function audit_external_log_format($data, $format = 'json') { return implode(' ', $fields) . "\n"; } - foreach (array('post', 'object_data', 'details') as $name) { + foreach (['post', 'object_data', 'details'] as $name) { if (isset($data[$name]) && is_string($data[$name])) { $decoded = audit_json_decode($data[$name], $error); @@ -361,7 +396,7 @@ function audit_external_log_format($data, $format = 'json') { return audit_json_encode($data, JSON_UNESCAPED_SLASHES) . "\n"; } -function audit_csv_safe_cell($value) { +function audit_csv_safe_cell(mixed $value): string { $value = (string) $value; if (preg_match('/^[=+\-@]/', ltrim($value))) { @@ -371,7 +406,7 @@ function audit_csv_safe_cell($value) { return $value; } -function audit_retention_cutoff($retention, $now = null) { +function audit_retention_cutoff(mixed $retention, ?DateTimeImmutable $now = null): DateTimeImmutable { $now = $now instanceof DateTimeImmutable ? $now->setTimezone(new DateTimeZone('UTC')) : new DateTimeImmutable('now', new DateTimeZone('UTC')); @@ -379,20 +414,28 @@ function audit_retention_cutoff($retention, $now = null) { return $now->sub(new DateInterval('P' . max(0, (int) $retention) . 'D')); } -function audit_append_external_log($path, $message) { +/** + * @return array + */ +function audit_append_external_log(string $path, string $message): array { if ($path == '' || !is_file($path) || is_link($path)) { - return array('status' => 'failed', 'error' => 'Destination is not a regular file or is a symbolic link.'); + return ['status' => 'failed', 'error' => 'Destination is not a regular file or is a symbolic link.']; } $written = file_put_contents($path, $message, FILE_APPEND | LOCK_EX); + if ($written !== strlen($message)) { - return array('status' => 'failed', 'error' => 'Unable to append a complete record.'); + return ['status' => 'failed', 'error' => 'Unable to append a complete record.']; } - return array('status' => 'delivered', 'error' => ''); + return ['status' => 'delivered', 'error' => '']; } -function audit_set_external_status($id, $status, $error = '') { +function audit_set_external_status(int $id, string $status, string $error = ''): void { + if (!audit_log_table_available()) { + return; + } + db_execute_prepared('UPDATE audit_log SET external_status = ?, external_error = ?, @@ -400,37 +443,41 @@ function audit_set_external_status($id, $status, $error = '') { external_last_attempt = UTC_TIMESTAMP(6), external_delivered_time = CASE WHEN ? = "delivered" THEN UTC_TIMESTAMP(6) ELSE external_delivered_time END WHERE id = ?', - array($status, $error, $status, $id)); + [$status, $error, $status, $id]); } -function audit_deliver_external_event($id) { - if (read_config_option('audit_log_external') != 'on') { +function audit_deliver_external_event(int $id): void { + if (!audit_log_table_available() || read_config_option('audit_log_external') != 'on') { return; } - $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id)); - if (!cacti_sizeof($event) || $event['request_status'] == 'started') { + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); + + if (is_array($event) && $event['request_status'] == 'started') { return; } $path = read_config_option('audit_log_external_path'); + if ($path == '' || !is_file($path) || is_link($path)) { audit_set_external_status($id, 'failed', 'Destination is not a regular file or is a symbolic link.'); + return; } $format = read_config_option('audit_log_external_format') === 'text' ? 'text' : 'json'; - $message = audit_external_log_format(audit_external_event_data($event), $format); + $message = audit_external_log_format(audit_external_event_data(is_array($event) ? $event : []), $format); $delivery = audit_append_external_log($path, $message); audit_set_external_status($id, $delivery['status'], $delivery['error']); } -function audit_retry_external_logs() { - if (read_config_option('audit_log_external') != 'on') { +function audit_retry_external_logs(): void { + if (!audit_log_table_available() || read_config_option('audit_log_external') != 'on') { return; } $path = read_config_option('audit_log_external_path'); + if ($path == '' || !is_file($path) || is_link($path)) { return; } @@ -445,19 +492,24 @@ function audit_retry_external_logs() { ORDER BY id LIMIT 100"); - foreach ($events as $event) { - $message = audit_external_log_format(audit_external_event_data($event), $format); - $delivery = audit_append_external_log($path, $message); - audit_set_external_status($event['id'], $delivery['status'], $delivery['error']); + if (is_array($events)) { + foreach ($events as $event) { + $message = audit_external_log_format(audit_external_event_data($event), $format); + $delivery = audit_append_external_log($path, $message); + audit_set_external_status($event['id'], $delivery['status'], $delivery['error']); - if ($delivery['status'] != 'delivered') { - break; + if ($delivery['status'] != 'delivered') { + break; + } } } } -function audit_request_status($error = null, $status_code = 200) { - $fatal_types = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR); +/** + * @param array $error + */ +function audit_request_status(?array $error = null, int $status_code = 200): string { + $fatal_types = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; if ((is_array($error) && in_array($error['type'] ?? null, $fatal_types, true)) || $status_code >= 400) { @@ -467,21 +519,27 @@ function audit_request_status($error = null, $status_code = 200) { return 'completed'; } -function audit_operation_verifier_for_request($page, $post) { +/** + * @param array $post + * @return array|null + */ +function audit_operation_verifier_for_request(string $page, array $post): ?array { 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', + return [ + 'type' => 'invalid', 'outcome_reason' => 'realm_permissions_request_invalid' - ); + ]; } - $expected_realm_ids = array(); + $expected_realm_ids = []; + foreach ($post as $field => $value) { $field = (string) $field; @@ -490,10 +548,10 @@ function audit_operation_verifier_for_request($page, $post) { } if (!preg_match('/^section([1-9][0-9]*)$/', $field, $matches)) { - return array( - 'type' => 'invalid', + return [ + 'type' => 'invalid', 'outcome_reason' => 'realm_permissions_request_invalid' - ); + ]; } $expected_realm_ids[] = (int) $matches[1]; @@ -502,55 +560,59 @@ function audit_operation_verifier_for_request($page, $post) { $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, + return [ + 'type' => 'user_realm_permissions', + 'target_user_id' => (int) $target_user_id, 'expected_realm_ids' => $expected_realm_ids - ); + ]; } -function audit_verify_operation($verifier) { +/** + * @return array + */ +function audit_verify_operation(mixed $verifier): array { if (!is_array($verifier) || empty($verifier['type'])) { - return array('outcome' => 'unknown', 'reason' => null); + return ['outcome' => 'unknown', 'reason' => null]; } if ($verifier['type'] == 'invalid') { - return array( + return [ 'outcome' => 'unknown', - 'reason' => $verifier['outcome_reason'] ?? 'verification_request_invalid' - ); + 'reason' => $verifier['outcome_reason'] ?? 'verification_request_invalid' + ]; } if ($verifier['type'] != 'user_realm_permissions') { - return array('outcome' => 'unknown', 'reason' => 'verification_type_unsupported'); + return ['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)); + $target_user_id = (int) ($verifier['target_user_id'] ?? 0); + $expected_realm_ids = $verifier['expected_realm_ids'] ?? []; + $user_count = db_fetch_cell_prepared('SELECT COUNT(*) FROM user_auth WHERE id = ?', [$target_user_id]); if ($user_count === false) { - return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'); + return ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed']; } if ((int) $user_count !== 1) { - return array('outcome' => 'failure', 'reason' => 'target_user_not_found'); + return ['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)); + [$target_user_id]); if (!is_array($rows)) { - return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'); + return ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed']; } - $actual_realm_ids = array(); + $actual_realm_ids = []; + foreach ($rows as $row) { if (!isset($row['realm_id']) || !is_numeric($row['realm_id'])) { - return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'); + return ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed']; } $actual_realm_ids[] = (int) $row['realm_id']; @@ -560,26 +622,33 @@ function audit_verify_operation($verifier) { sort($actual_realm_ids, SORT_NUMERIC); if ($actual_realm_ids === $expected_realm_ids) { - return array('outcome' => 'success', 'reason' => 'realm_permissions_verified'); + return ['outcome' => 'success', 'reason' => 'realm_permissions_verified']; } - return array('outcome' => 'failure', 'reason' => 'realm_permissions_mismatch'); + return ['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; +/** + * @param array|null $verifier + */ +function audit_finalize_request(int $id, ?float $started_at = null, ?array $verifier = null): void { + if (!audit_log_table_available()) { + return; + } + + $status_code = http_response_code(); + $status_code = is_int($status_code) ? $status_code : 200; $request_status = audit_request_status(error_get_last(), $status_code); - $outcome = $request_status == 'failed' ? 'failure' : 'unknown'; + $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']; + $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)); + $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 @@ -591,20 +660,24 @@ function audit_finalize_request($id, $started_at = null, $verifier = null) { duration_ms = ? WHERE id = ? AND request_status = 'started'", - array($request_status, $outcome_reason, $outcome, $status_code, $completed_time, $duration_ms, $id)); + [$request_status, $outcome_reason, $outcome, $status_code, $completed_time, $duration_ms, $id]); + + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); - $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id)); - if (cacti_sizeof($event)) { + if (is_array($event)) { db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', - array(audit_event_integrity_hash($event), $id)); + [audit_event_integrity_hash($event), $id]); } audit_deliver_external_event($id); audit_enqueue_syslog_event($id); } -function audit_record_event($event_type, $options = array()) { - if (read_config_option('audit_enabled') != 'on') { +/** + * @param array $options + */ +function audit_record_event(string $event_type, array $options = []): int { + if (!audit_log_table_available() || read_config_option('audit_enabled') != 'on') { return 0; } @@ -615,19 +688,19 @@ function audit_record_event($event_type, $options = array()) { $event_suffix = strrchr($event_type, '.'); $action = $options['action'] ?? ($event_suffix === false ? $event_type : substr($event_suffix, 1)); $event_time = $options['event_time'] ?? audit_utc_time(); - $details = audit_json_encode(audit_redact_sensitive_data($options['details'] ?? array())); + $details = audit_json_encode(audit_redact_sensitive_data($options['details'] ?? [])); $external = read_config_option('audit_log_external') == 'on'; $ip_address = $options['ip_address'] ?? (function_exists('get_client_addr') ? get_client_addr() : ''); $user_agent = $options['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? ''); - db_execute_prepared('INSERT INTO audit_log ( + $inserted = db_execute_prepared('INSERT INTO audit_log ( page, user_id, action, request_status, ip_address, user_agent, event_time, post, object_data, external_status, event_uuid, correlation_id, event_type, event_category, severity, actor_type, target_type, target_id, operation_outcome, outcome_reason, http_method, http_status, completed_time, duration_ms, details ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - array( + [ $page, $user_id, $action, 'completed', $ip_address, $user_agent, $event_time, '{}', '[]', $external ? 'pending' : 'disabled', $event_uuid, $correlation_id, $event_type, $options['event_category'] ?? 'security', @@ -637,33 +710,518 @@ function audit_record_event($event_type, $options = array()) { $options['http_method'] ?? ($_SERVER['REQUEST_METHOD'] ?? null), $options['http_status'] ?? null, $options['completed_time'] ?? $event_time, $options['duration_ms'] ?? 0, $details - )); + ]); + + if (!$inserted) { + return 0; + } - $id = db_fetch_insert_id(); - $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id)); - if (cacti_sizeof($event)) { + $id = (int) db_fetch_insert_id(); + + if ($id <= 0) { + return 0; + } + + $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); + + if (is_array($event)) { db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', - array(audit_event_integrity_hash($event), $id)); + [audit_event_integrity_hash($event), $id]); + } + + if (empty($options['defer_delivery'])) { + audit_deliver_external_event($id); + audit_enqueue_syslog_event($id); } - audit_deliver_external_event($id); - audit_enqueue_syslog_event($id); return $id; } -function audit_logout_pre_session_destroy() { +function audit_logout_pre_session_destroy(): void { + // Stash the logging-out user identity and request correlation id so the + // post-destroy hook can confirm session teardown after $_SESSION is gone. + // This runs regardless of the auth-audit switch so the stash is available + // if the switch is toggled on between the two hooks (unlikely but safe). + audit_logout_stash([ + 'user_id' => (int) ($_SESSION['sess_user_id'] ?? 0), + 'correlation_id' => audit_request_correlation_id(), + 'reason' => get_nfilter_request_var('action', 'user') + ]); + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + $reason = get_nfilter_request_var('action', 'user'); $type = $reason == 'timeout' ? 'authentication.session.expired' : 'authentication.logout'; - audit_record_event($type, array( + audit_record_event($type, [ 'event_category' => 'authentication', - 'action' => $reason == 'timeout' ? 'timeout' : 'logout', - 'details' => array('reason' => $reason) - )); + 'action' => $reason == 'timeout' ? 'timeout' : 'logout', + 'details' => ['reason' => $reason] + ]); +} + +/** + * Per-request stash shared between the pre- and post-destroy logout hooks. + * + * @param array|null $set + * @return array + */ +function audit_logout_stash(?array $set = null): array { + static $stash = []; + + if (is_array($set)) { + $stash = $set; + } + + return $stash; } -function audit_enforce_syslog_settings_request() { - $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); +function audit_logout_post_session_destroy(): void { + if (read_config_option('audit_enabled') != 'on') { + return; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + + $stash = audit_logout_stash(); + + if (empty($stash)) { + return; + } + + audit_record_event('authentication.logout.completed', [ + 'event_category' => 'authentication', + 'action' => 'logout_completed', + 'user_id' => $stash['user_id'] ?? 0, + 'correlation_id' => $stash['correlation_id'] ?? audit_request_correlation_id(), + 'operation_outcome' => 'success', + 'details' => [ + 'reason' => $stash['reason'] ?? 'user', + 'session_destroyed' => true + ] + ]); + + audit_logout_stash([]); +} + +/** + * Map a Cacti user_log result code to an audit event descriptor. + * + * Cacti writes user_log rows with result codes: + * 0 = Failed login + * 1 = Credentials accepted (written BEFORE enabled/realm/2FA checks) + * 2 = Success - Token (remember-me or 2FA) + * 3 = Password Change OR failed 2FA (user_id is omitted, defaulting to 0) + * + * Cacti writes result=1 before verifying that the account is enabled, + * authorized for any realm, or has completed 2FA, so it does not prove an + * authenticated session was established. It is recorded as credentials + * accepted with operation_outcome=unknown, not success. + * + * Cacti's password-change inserts and the develop branch's failed-2FA inserts + * both write result=3 with user_id=0, so user_log alone cannot disambiguate + * them; that combination is recorded as an ambiguous event with + * operation_outcome=unknown. No current Cacti path writes result=3 with + * user_id>0, but a future version may; that is treated defensively as a + * password change with outcome=unknown rather than claiming a confirmed + * password change. + * + * Any unsupported result code is recorded as an explicit unknown event rather + * than falling through to a password-change or 2FA event. + * + * @return array{event_type:string,severity:string,outcome:string,action:string,details:array} + */ +function audit_user_log_event_descriptor(int $result, int $user_id): array { + if ($result === 0) { + return [ + 'event_type' => 'cacti.auth.login.failed', + 'severity' => 'warning', + 'outcome' => 'failure', + 'action' => 'login_failed', + 'details' => [] + ]; + } + + if ($result === 1) { + return [ + 'event_type' => 'cacti.auth.login.credentials_accepted', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'credentials_accepted', + 'details' => [ + 'note' => __('Cacti records this outcome before verifying account enabled, realm authorization, or 2FA completion; a session may not have been established.', 'audit') + ] + ]; + } + + if ($result === 2) { + return [ + 'event_type' => 'cacti.auth.login.token', + 'severity' => 'info', + 'outcome' => 'success', + 'action' => 'login_token', + 'details' => [] + ]; + } + + if ($result === 3 && $user_id > 0) { + return [ + 'event_type' => 'cacti.auth.password.changed', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'password_changed', + 'details' => [ + 'note' => __('No current Cacti path writes this signature; recorded defensively as a possible password change with an unconfirmed outcome.', 'audit') + ] + ]; + } + + if ($result === 3) { + return [ + 'event_type' => 'cacti.auth.password_change_or_2fa_failed', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'password_change_or_2fa_failed', + 'details' => [ + 'ambiguous' => true, + 'note' => __('Cacti user_log result=3 with user_id=0 may be a password change or a failed 2FA challenge; the table cannot disambiguate.', 'audit') + ] + ]; + } + + // Unsupported result code: record explicitly as unknown rather than + // falling through to a password-change or 2FA event. + return [ + 'event_type' => 'cacti.auth.login.unknown', + 'severity' => 'info', + 'outcome' => 'unknown', + 'action' => 'unknown_result', + 'details' => [ + 'unsupported_result_code' => $result + ] + ]; +} + +/** + * Compute a deterministic SHA-256 source identity for a user_log row. + */ +function audit_user_log_source_hash(string $username, int $user_id, string $time): string { + return hash('sha256', $username . '|' . $user_id . '|' . $time); +} + +/** + * Poll Cacti's user_log table for new login/logout/token/password-change + * outcomes and record them as audit events. The user_log table is the + * authoritative source across all auth methods (local, LDAP, basic, domains) + * and is stable across the 1.2.x and develop branches, so this avoids + * relying on the local-auth-only login_process hook. + * + * Deduplication is durable and database-backed: each processed user_log + * primary-key tuple (username, user_id, time) is recorded in + * audit_user_log_state as a deterministic SHA-256 hash. The audit event and + * state marker are committed atomically; a concurrent loser rolls back its + * duplicate event before any external delivery occurs. + * + * Each cycle selects a bounded batch of recent user_log rows that have no + * state marker. This anti-join approach keeps failed inserts and late commits + * discoverable instead of advancing a high-water cursor past them. The + * retention cutoff prevents arbitrary historical backfill. + */ +function audit_poll_user_log(): void { + if (read_config_option('audit_enabled') != 'on') { + return; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + + if (!function_exists('db_table_exists') || !db_table_exists('user_log')) { + return; + } + + if (!db_table_exists('audit_user_log_state')) { + return; + } + + $batch_size = (int) read_config_option('audit_user_log_batch_size'); + + if ($batch_size < 1) { + $batch_size = 1000; + } elseif ($batch_size > 5000) { + $batch_size = 5000; + } + + $retention = (int) read_config_option('audit_retention'); + + if ($retention <= 0) { + $retention = 90; + } + + $cutoff = audit_retention_cutoff($retention)->format('Y-m-d H:i:s'); + $rows = db_fetch_assoc_prepared( + 'SELECT ul.username, ul.user_id, ul.result, ul.ip, ul.time + FROM user_log AS ul + WHERE ul.time > ? + AND NOT EXISTS ( + SELECT 1 + FROM audit_user_log_state AS auls + WHERE auls.source_hash = SHA2( + CONCAT(ul.username, "|", ul.user_id, "|", ul.time), + 256 + ) + ) + ORDER BY ul.time ASC, ul.username ASC, ul.user_id ASC + LIMIT ' . $batch_size, + [$cutoff] + ); + + if (!is_array($rows) || cacti_sizeof($rows) === 0) { + return; + } + + $now_utc = audit_utc_time(); + + foreach ($rows as $row) { + $result = (int) $row['result']; + $user_id = (int) $row['user_id']; + $time = (string) $row['time']; + $username = (string) $row['username']; + + $source_hash = audit_user_log_source_hash($username, $user_id, $time); + $source_key = $username . '|' . $user_id . '|' . $time; + + if (!db_execute_prepared('START TRANSACTION')) { + continue; + } + + $descriptor = audit_user_log_event_descriptor($result, $user_id); + + $audit_id = audit_record_event($descriptor['event_type'], [ + 'event_category' => 'authentication', + 'action' => $descriptor['action'], + 'severity' => $descriptor['severity'], + 'operation_outcome' => $descriptor['outcome'], + 'actor_type' => $user_id > 0 ? 'user' : 'anonymous', + 'target_type' => 'user_account', + 'target_id' => $user_id > 0 ? (string) $user_id : $username, + 'ip_address' => (string) ($row['ip'] ?? ''), + 'user_agent' => '', + 'page' => 'user_log.php', + 'event_time' => $time, + 'defer_delivery' => true, + 'details' => [ + 'username' => $username, + 'result_code' => $result, + 'source_table' => 'user_log', + 'descriptor' => $descriptor['details'] + ] + ]); + + if ($audit_id <= 0) { + db_execute_prepared('ROLLBACK'); + + continue; + } + + $state_inserted = db_execute_prepared( + 'INSERT IGNORE INTO audit_user_log_state + (source_hash, source_key, source_time, audit_id, processed_time) + VALUES (?, ?, ?, ?, ?)', + [$source_hash, $source_key, $time, $audit_id, $now_utc] + ); + + if (!$state_inserted || db_affected_rows() !== 1) { + db_execute_prepared('ROLLBACK'); + + continue; + } + + if (!db_execute_prepared('COMMIT')) { + db_execute_prepared('ROLLBACK'); + + continue; + } + + audit_deliver_external_event($audit_id); + audit_enqueue_syslog_event($audit_id); + } +} + +/** + * Detect brute-force login patterns by counting failed user_log entries + * within a rolling window. Emits a single critical audit event per window + * to avoid alert flooding. + */ +function audit_detect_brute_force(): void { + if (read_config_option('audit_enabled') != 'on') { + return; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return; + } + + if (read_config_option('audit_brute_force_enabled') != 'on') { + return; + } + + if (!function_exists('db_table_exists') || !db_table_exists('user_log')) { + return; + } + + $window = (int) read_config_option('audit_brute_force_window_minutes'); + + if ($window < 1) { + $window = 5; + } elseif ($window > 1440) { + $window = 1440; + } + + $threshold = (int) read_config_option('audit_brute_force_threshold'); + + if ($threshold < 1) { + $threshold = 10; + } elseif ($threshold > 1000) { + $threshold = 1000; + } + + $count = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) + FROM user_log + WHERE result = 0 + AND time >= DATE_SUB(NOW(), INTERVAL ? MINUTE)', + [$window] + ); + + if ($count < $threshold) { + return; + } + + // Atomically claim the alert slot so two concurrent pollers cannot both + // emit for the same window. The conditional UPDATE only succeeds if the + // last alert is empty or older than the window; the affected-row count + // proves ownership. The settings row is created defensively first so a + // fresh install can participate in the same atomic claim. + $now = gmdate('Y-m-d H:i:s'); + + $initialized = db_execute_prepared( + 'INSERT IGNORE INTO settings (name, value) VALUES (?, ?)', + ['audit_brute_force_last_alert', ''] + ); + + if (!$initialized) { + return; + } + + $claimed = db_execute_prepared( + "UPDATE settings + SET value = ? + WHERE name = 'audit_brute_force_last_alert' + AND (value = '' OR value = '0' + OR STR_TO_DATE(value, '%Y-%m-%d %H:%i:%s') < DATE_SUB(?, INTERVAL ? MINUTE))", + [$now, $now, $window] + ); + + if (!$claimed || db_affected_rows() < 1) { + return; + } + + $audit_id = audit_record_event('cacti.auth.brute_force_suspected', [ + 'event_category' => 'authentication', + 'action' => 'brute_force_suspected', + 'severity' => 'critical', + 'operation_outcome' => 'failure', + 'actor_type' => 'system', + 'target_type' => 'authentication', + 'details' => [ + 'failed_attempts' => $count, + 'window_minutes' => $window, + 'threshold' => $threshold + ] + ]); + + // Keep the claimed timestamp after a confirmed successful audit insert. + // If the insert failed, release the slot so the next poller can retry. + if ($audit_id > 0) { + set_config_option('audit_brute_force_last_alert', $now); + } else { + set_config_option('audit_brute_force_last_alert', ''); + } +} + +/** + * Hook handler for Cacti's custom_denied hook. Records an authorization- + * denied event and returns the input mode unchanged so Cacti continues + * rendering its default permission-denied page. + * + * @param mixed $mode + * @return mixed + */ +function audit_custom_denied(mixed $mode): mixed { + if (read_config_option('audit_enabled') != 'on') { + return $mode; + } + + if (read_config_option('audit_auth_log_enabled') != 'on') { + return $mode; + } + + $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + $user_id = (int) ($_SESSION['sess_user_id'] ?? 0); + + // Record only the referer origin and path; strip the query string to + // avoid leaking tokens, reset hashes, OAuth state, or session + // identifiers into the audit log and external syslog consumers. + $safe_referer = ''; + + if ($referer !== '') { + $parsed = parse_url((string) $referer); + $safe_ref = ''; + + if (is_array($parsed)) { + if (isset($parsed['scheme']) && isset($parsed['host'])) { + $safe_ref = $parsed['scheme'] . '://' . $parsed['host']; + + if (isset($parsed['port'])) { + $safe_ref .= ':' . $parsed['port']; + } + } + + if (isset($parsed['path'])) { + $safe_ref .= $parsed['path']; + } + } + + $safe_referer = $safe_ref !== '' ? $safe_ref : '[unparseable]'; + } + + audit_record_event('cacti.auth.authorization.denied', [ + 'event_category' => 'authentication', + 'action' => 'authorization_denied', + 'severity' => 'warning', + 'operation_outcome' => 'failure', + 'actor_type' => $user_id > 0 ? 'user' : 'anonymous', + 'target_type' => 'page', + 'target_id' => $page, + 'page' => $page, + 'details' => [ + 'requested_page' => $page, + 'referer_origin' => $safe_referer, + 'referer_redacted' => $referer !== $safe_referer + ] + ]); + + return $mode; +} + +function audit_enforce_syslog_settings_request(): void { + $page = basename($_SERVER['SCRIPT_NAME'] ?? ''); $method = $_SERVER['REQUEST_METHOD'] ?? ''; if ($page !== 'settings.php' || $method !== 'POST') { @@ -671,66 +1229,91 @@ function audit_enforce_syslog_settings_request() { } $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW); - $post = is_array($post) ? $post : array(); + $post = is_array($post) ? $post : []; + if (($post['action'] ?? '') !== 'save' || ($post['tab'] ?? '') !== 'audit') { return; } $has_syslog_fields = false; + $has_auth_fields = false; + foreach ($post as $name => $value) { - if (strpos((string) $name, 'audit_syslog_') === 0) { + $name = (string) $name; + + if (strpos($name, 'audit_syslog_') === 0) { $has_syslog_fields = true; - break; + } elseif ( + strpos($name, 'audit_auth_') === 0 || + strpos($name, 'audit_brute_force_') === 0 || + $name === 'audit_user_log_batch_size' + ) { + $has_auth_fields = true; } } - if (!$has_syslog_fields) { + if (!$has_syslog_fields && !$has_auth_fields) { return; } if (!audit_user_is_admin()) { - audit_record_event('audit.syslog.configuration.denied', array( - 'event_category' => 'audit', - 'severity' => 'warning', - 'action' => 'save', - 'target_type' => 'syslog_configuration', - 'operation_outcome' => 'failure', - 'outcome_reason' => 'audit_admin_required' - )); + // Preserve the syslog-specific denied event when syslog fields are + // part of the unauthorized save; use a generic audit-configuration + // event when only authentication/brute-force fields are present. + if ($has_syslog_fields) { + audit_record_event('audit.syslog.configuration.denied', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'save', + 'target_type' => 'syslog_configuration', + 'operation_outcome' => 'failure', + 'outcome_reason' => 'audit_admin_required' + ]); + } else { + audit_record_event('audit.configuration.denied', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'save', + 'target_type' => 'audit_configuration', + 'operation_outcome' => 'failure', + 'outcome_reason' => 'audit_admin_required' + ]); + } + http_response_code(403); exit; } - $names = array( + $names = [ 'receiver', 'port', 'transport', 'format', 'facility', 'application', 'node_id', 'timeout', 'udp_max_size', 'retry_base', 'retry_max', 'max_attempts', 'batch_size', 'pending_age_warning', 'dead_letter_warning', 'tls_ca_file', 'tls_client_cert', 'tls_client_key' - ); - $overrides = array(); + ]; + $overrides = []; foreach ($names as $name) { - $setting = 'audit_syslog_' . $name; + $setting = 'audit_syslog_' . $name; $overrides[$name] = isset($post[$setting]) && is_scalar($post[$setting]) ? (string) $post[$setting] : ''; } - $config = audit_syslog_config($overrides); - $enabling = isset($post['audit_syslog_enabled']) && $post['audit_syslog_enabled'] === 'on'; - $configuring = trim($overrides['receiver']) !== ''; + $config = audit_syslog_config($overrides); + $enabling = isset($post['audit_syslog_enabled']) && $post['audit_syslog_enabled'] === 'on'; + $configuring = trim($overrides['receiver'] ?? '') !== ''; if (($enabling || $configuring) && !$config['valid']) { - audit_record_event('audit.syslog.configuration.rejected', array( - 'event_category' => 'audit', - 'severity' => 'warning', - 'action' => 'save', - 'target_type' => 'syslog_configuration', + audit_record_event('audit.syslog.configuration.rejected', [ + 'event_category' => 'audit', + 'severity' => 'warning', + 'action' => 'save', + 'target_type' => 'syslog_configuration', 'operation_outcome' => 'failure', - 'outcome_reason' => 'configuration_invalid', - 'details' => array('errors' => $config['errors']) - )); + 'outcome_reason' => 'configuration_invalid', + 'details' => ['errors' => $config['errors']] + ]); raise_message( 'audit_syslog_configuration', @@ -742,38 +1325,36 @@ function audit_enforce_syslog_settings_request() { } } - - -function audit_config_insert() { +function audit_config_insert(): void { global $action, $config; audit_enforce_syslog_settings_request(); if (audit_log_valid_event()) { $started_at = microtime(true); - /* prepare post */ + // prepare post $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW); - $post = is_array($post) ? $post : array(); + $post = is_array($post) ? $post : []; - /* remove unsafe variables */ + // remove unsafe variables unset($post['__csrf_magic']); unset($post['header']); $post = audit_redact_sensitive_data($post); - /* check if drp_action is present and update action accordingly */ + // check if drp_action is present and update action accordingly if (isset($post['drp_action']) && $post['drp_action'] == 1) { $action = 'delete'; - } else if (isset($post['drp_action']) && $post['drp_action'] == 4) { + } elseif (isset($post['drp_action']) && $post['drp_action'] == 4) { $action = 'disable'; } - /* sanitize and serialize selected items */ + // sanitize and serialize selected items if (isset($post['selected_items']) && is_string($post['selected_items'])) { - $selected_items = @unserialize(stripslashes($post['selected_items']), array('allowed_classes' => false)); - $selected_items = is_array($selected_items) ? $selected_items : array(); + $selected_items = @unserialize(stripslashes($post['selected_items']), ['allowed_classes' => false]); + $selected_items = is_array($selected_items) ? $selected_items : []; $drop_action = $post['drp_action'] ?? false; } else { - $selected_items = array(); + $selected_items = []; $drop_action = false; } @@ -784,10 +1365,10 @@ function audit_config_insert() { $user_id = (isset($_SESSION['sess_user_id']) ? $_SESSION['sess_user_id'] : 0); $event_time = audit_utc_time($started_at); - /* Retrieve IP address */ + // Retrieve IP address $ip_address = get_client_addr(); - /* Get the User Agent */ + // Get the User Agent $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; if (empty($action) && isset_request_var('action')) { @@ -803,9 +1384,11 @@ function audit_config_insert() { switch ($drop_action) { case 2: $action = 'Delete Device'; + break; case 1: $action = 'Create Device'; + break; } @@ -814,16 +1397,18 @@ function audit_config_insert() { switch ($drop_action) { case 2: $action = 'Host Enabled'; + break; case 3: $action = 'Host Disabled'; + break; } break; } - $audit_log = read_config_option('audit_log_external_path'); + $audit_log = read_config_option('audit_log_external_path'); $external_logging = read_config_option('audit_log_external') == 'on'; $external_status = $external_logging ? 'pending' : 'disabled'; @@ -836,20 +1421,20 @@ function audit_config_insert() { $event_uuid = audit_uuid_v4(); $correlation_id = audit_request_correlation_id(); $event_type = audit_event_type_for_request($page, $action); - $category = in_array($page, array('user_admin.php', 'user_group_admin.php'), true) ? 'identity_access' : 'configuration'; + $category = in_array($page, ['user_admin.php', 'user_group_admin.php'], true) ? 'identity_access' : 'configuration'; db_execute_prepared('INSERT INTO audit_log ( page, user_id, action, request_status, ip_address, user_agent, event_time, post, object_data, external_status, event_uuid, correlation_id, event_type, event_category, severity, actor_type, target_type, target_id, operation_outcome, http_method ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - array( + [ $page, $user_id, $action, 'started', $ip_address, $user_agent, $event_time, $post, $object_data, $external_status, $event_uuid, $correlation_id, $event_type, $category, 'info', $user_id ? 'user' : 'system', preg_replace('/\.php$/', '', $page), $target_id, 'unknown', $_SERVER['REQUEST_METHOD'] ?? null - )); + ]); $audit_id = db_fetch_insert_id(); register_shutdown_function('audit_finalize_request', $audit_id, $started_at, $verifier); @@ -861,6 +1446,7 @@ function audit_config_insert() { if ($external_logging && $audit_log != '' && !file_exists($audit_log)) { if (is_writable(dirname($audit_log))) { cacti_log(sprintf('NOTE: The Audit Log file \'%s\' does not exist. Creating it.', $audit_log), false, 'AUDIT'); + if (!touch($audit_log)) { cacti_log(sprintf('ERROR: Unable to create Audit Log file \'%s\'.', $audit_log), false, 'AUDIT'); } else { @@ -882,18 +1468,17 @@ function audit_config_insert() { $page = basename($arguments[0]); $user_id = 0; $action = 'cli'; - $ip_address = getHostByName(php_uname('n')); + $ip_address = gethostbyname(php_uname('n')); $user_agent = get_current_user(); $event_time = audit_utc_time($started_at); $post = implode(' ', $arguments); - /* don't insert poller records */ - if (strpos($arguments[0], 'poller') === false && - strpos($arguments[0], 'cmd.php') === false && - strpos($arguments[0], '/scripts/') === false && + // don't insert poller records + if (strpos($arguments[0], 'poller') === false && + strpos($arguments[0], 'cmd.php') === false && + strpos($arguments[0], '/scripts/') === false && strpos($arguments[0], 'script_server.php') === false && - strpos($arguments[0], '_process.php') === false) { - + strpos($arguments[0], '_process.php') === false) { $external_status = read_config_option('audit_log_external') == 'on' ? 'pending' : 'disabled'; db_execute_prepared('INSERT INTO audit_log ( page, user_id, action, request_status, ip_address, user_agent, event_time, @@ -901,12 +1486,12 @@ function audit_config_insert() { event_category, severity, actor_type, target_type, target_id, operation_outcome ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - array( + [ $page, $user_id, $action, 'started', $ip_address, $user_agent, $event_time, $post, '[]', $external_status, audit_uuid_v4(), audit_request_correlation_id(), 'cacti.cli.executed', 'system', 'info', 'system', 'cli_command', $page, 'unknown' - )); + ]); $audit_id = db_fetch_insert_id(); register_shutdown_function('audit_finalize_request', $audit_id, $started_at); } diff --git a/audit_syslog.php b/audit_syslog.php index f73f406..46245bf 100644 --- a/audit_syslog.php +++ b/audit_syslog.php @@ -15,32 +15,38 @@ +-------------------------------------------------------------------------+ */ -function audit_syslog_enabled() { +function audit_syslog_enabled(): bool { return read_config_option('audit_syslog_enabled') == 'on'; } -function audit_syslog_read_setting($name, $default) { +function audit_syslog_read_setting(string $name, mixed $default): mixed { $value = read_config_option($name); return $value === '' || $value === null ? $default : $value; } -function audit_syslog_bounded_integer($value, $default, $minimum, $maximum, &$errors, $name) { +/** + * @param array $errors + */ +function audit_syslog_bounded_integer(mixed $value, int $default, int $minimum, int $maximum, array &$errors, string $name): int { if (!is_scalar($value) || !preg_match('/^[0-9]+$/', (string) $value)) { $errors[] = $name . '_invalid'; + return $default; } $value = (int) $value; + if ($value < $minimum || $value > $maximum) { $errors[] = $name . '_out_of_range'; + return $default; } return $value; } -function audit_syslog_valid_receiver($receiver) { +function audit_syslog_valid_receiver(string $receiver): bool { if ($receiver === '' || strlen($receiver) > 253 || preg_match('/[[:cntrl:][:space:]\\/@]/', $receiver) || strpos($receiver, '://') !== false) { @@ -60,6 +66,7 @@ function audit_syslog_valid_receiver($receiver) { } $labels = explode('.', $receiver); + foreach ($labels as $label) { if ($label === '' || strlen($label) > 63 || !preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i', $label)) { @@ -70,12 +77,15 @@ function audit_syslog_valid_receiver($receiver) { return true; } -function audit_syslog_valid_header_value($value, $maximum) { +function audit_syslog_valid_header_value(string $value, int $maximum): bool { return $value !== '' && strlen($value) <= $maximum && !preg_match('/[^\\x21-\\x7e]|[\\[\\]="]/', $value); } -function audit_syslog_validate_optional_file($path, $name, &$errors) { +/** + * @param array $errors + */ +function audit_syslog_validate_optional_file(string $path, string $name, array &$errors): string { if ($path === '') { return ''; } @@ -87,93 +97,103 @@ function audit_syslog_validate_optional_file($path, $name, &$errors) { return $path; } -function audit_syslog_config($overrides = array()) { - $defaults = array( - 'receiver' => '', - 'port' => '', - 'transport' => 'udp', - 'format' => 'json', - 'facility' => 'local0', - 'application' => 'cacti-audit', - 'node_id' => php_uname('n'), - 'timeout' => '5', - 'udp_max_size' => '8192', - 'retry_base' => '30', - 'retry_max' => '3600', - 'max_attempts' => '10', - 'batch_size' => '100', +/** + * @param array $overrides + * @return array + */ +function audit_syslog_config(array $overrides = []): array { + $defaults = [ + 'receiver' => '', + 'port' => '', + 'transport' => 'udp', + 'format' => 'json', + 'facility' => 'local0', + 'application' => 'cacti-audit', + 'node_id' => php_uname('n'), + 'timeout' => '5', + 'udp_max_size' => '8192', + 'retry_base' => '30', + 'retry_max' => '3600', + 'max_attempts' => '10', + 'batch_size' => '100', 'pending_age_warning' => '900', 'dead_letter_warning' => '1', - 'tls_ca_file' => '', - 'tls_client_cert' => '', - 'tls_client_key' => '' - ); - $values = array(); + 'tls_ca_file' => '', + 'tls_client_cert' => '', + 'tls_client_key' => '' + ]; + $values = []; foreach ($defaults as $name => $default) { - $setting = 'audit_syslog_' . $name; + $setting = 'audit_syslog_' . $name; $values[$name] = array_key_exists($name, $overrides) ? $overrides[$name] : audit_syslog_read_setting($setting, $default); } - $errors = array(); - $receiver = trim((string) $values['receiver']); + $errors = []; + $receiver = trim((string) ($values['receiver'] ?? '')); + if (!audit_syslog_valid_receiver($receiver)) { $errors[] = 'receiver_invalid'; } - $transport = strtolower((string) $values['transport']); - if (!in_array($transport, array('udp', 'tcp', 'tls'), true)) { - $errors[] = 'transport_invalid'; + $transport = strtolower((string) ($values['transport'] ?? '')); + + if (!in_array($transport, ['udp', 'tcp', 'tls'], true)) { + $errors[] = 'transport_invalid'; $transport = 'udp'; } - $format = strtolower((string) $values['format']); - if (!in_array($format, array('rfc5424', 'cef', 'json'), true)) { + $format = strtolower((string) ($values['format'] ?? '')); + + if (!in_array($format, ['rfc5424', 'cef', 'json'], true)) { $errors[] = 'format_invalid'; - $format = 'json'; + $format = 'json'; } $facility_map = audit_syslog_facilities(); - $facility = strtolower((string) $values['facility']); + $facility = strtolower((string) ($values['facility'] ?? '')); + if (!isset($facility_map[$facility])) { $errors[] = 'facility_invalid'; $facility = 'local0'; } - $application = trim((string) $values['application']); + $application = trim((string) ($values['application'] ?? '')); + if (!audit_syslog_valid_header_value($application, 48)) { - $errors[] = 'application_invalid'; + $errors[] = 'application_invalid'; $application = 'cacti-audit'; } - $node_id = trim((string) $values['node_id']); + $node_id = trim((string) ($values['node_id'] ?? '')); + if (!audit_syslog_valid_header_value($node_id, 255)) { $errors[] = 'node_id_invalid'; - $node_id = 'cacti'; + $node_id = 'cacti'; } - $port = trim((string) $values['port']) === '' + $port = trim((string) ($values['port'] ?? '')) === '' ? ($transport === 'tls' ? 6514 : 514) - : audit_syslog_bounded_integer($values['port'], $transport === 'tls' ? 6514 : 514, 1, 65535, $errors, 'port'); - $timeout = audit_syslog_bounded_integer($values['timeout'], 5, 1, 30, $errors, 'timeout'); - $udp_max_size = audit_syslog_bounded_integer($values['udp_max_size'], 8192, 512, 65507, $errors, 'udp_max_size'); - $retry_base = audit_syslog_bounded_integer($values['retry_base'], 30, 1, 3600, $errors, 'retry_base'); - $retry_max = audit_syslog_bounded_integer($values['retry_max'], 3600, 1, 86400, $errors, 'retry_max'); - $max_attempts = audit_syslog_bounded_integer($values['max_attempts'], 10, 1, 100, $errors, 'max_attempts'); - $batch_size = audit_syslog_bounded_integer($values['batch_size'], 100, 1, 1000, $errors, 'batch_size'); - $pending_age_warning = audit_syslog_bounded_integer($values['pending_age_warning'], 900, 60, 604800, $errors, 'pending_age_warning'); - $dead_letter_warning = audit_syslog_bounded_integer($values['dead_letter_warning'], 1, 1, 1000000, $errors, 'dead_letter_warning'); + : audit_syslog_bounded_integer($values['port'] ?? '', $transport === 'tls' ? 6514 : 514, 1, 65535, $errors, 'port'); + $timeout = audit_syslog_bounded_integer($values['timeout'] ?? 5, 5, 1, 30, $errors, 'timeout'); + $udp_max_size = audit_syslog_bounded_integer($values['udp_max_size'] ?? 8192, 8192, 512, 65507, $errors, 'udp_max_size'); + $retry_base = audit_syslog_bounded_integer($values['retry_base'] ?? 30, 30, 1, 3600, $errors, 'retry_base'); + $retry_max = audit_syslog_bounded_integer($values['retry_max'] ?? 3600, 3600, 1, 86400, $errors, 'retry_max'); + $max_attempts = audit_syslog_bounded_integer($values['max_attempts'] ?? 10, 10, 1, 100, $errors, 'max_attempts'); + $batch_size = audit_syslog_bounded_integer($values['batch_size'] ?? 100, 100, 1, 1000, $errors, 'batch_size'); + $pending_age_warning = audit_syslog_bounded_integer($values['pending_age_warning'] ?? 900, 900, 60, 604800, $errors, 'pending_age_warning'); + $dead_letter_warning = audit_syslog_bounded_integer($values['dead_letter_warning'] ?? 1, 1, 1, 1000000, $errors, 'dead_letter_warning'); if ($retry_max < $retry_base) { - $errors[] = 'retry_max_less_than_base'; + $errors[] = 'retry_max_less_than_base'; $retry_max = $retry_base; } - $tls_ca_file = trim((string) $values['tls_ca_file']); - $tls_client_cert = trim((string) $values['tls_client_cert']); - $tls_client_key = trim((string) $values['tls_client_key']); + $tls_ca_file = trim((string) ($values['tls_ca_file'] ?? '')); + $tls_client_cert = trim((string) ($values['tls_client_cert'] ?? '')); + $tls_client_key = trim((string) ($values['tls_client_key'] ?? '')); if ($transport === 'tls') { audit_syslog_validate_optional_file($tls_ca_file, 'tls_ca_file', $errors); @@ -185,89 +205,96 @@ function audit_syslog_config($overrides = array()) { } } - $config = array( - 'receiver' => $receiver, - 'port' => $port, - 'transport' => $transport, - 'format' => $format, - 'facility' => $facility, - 'application' => $application, - 'node_id' => $node_id, - 'timeout' => $timeout, - 'udp_max_size' => $udp_max_size, - 'retry_base' => $retry_base, - 'retry_max' => $retry_max, - 'max_attempts' => $max_attempts, - 'batch_size' => $batch_size, - 'pending_age_warning' => $pending_age_warning, - 'dead_letter_warning' => $dead_letter_warning, - 'tls_ca_file' => $tls_ca_file, - 'tls_client_cert' => $tls_client_cert, - 'tls_client_key' => $tls_client_key, - 'poller_id' => defined('POLLER_ID') ? (string) POLLER_ID : '', - 'tls_verify_peer' => true, + $config = [ + 'receiver' => $receiver, + 'port' => $port, + 'transport' => $transport, + 'format' => $format, + 'facility' => $facility, + 'application' => $application, + 'node_id' => $node_id, + 'timeout' => $timeout, + 'udp_max_size' => $udp_max_size, + 'retry_base' => $retry_base, + 'retry_max' => $retry_max, + 'max_attempts' => $max_attempts, + 'batch_size' => $batch_size, + 'pending_age_warning' => $pending_age_warning, + 'dead_letter_warning' => $dead_letter_warning, + 'tls_ca_file' => $tls_ca_file, + 'tls_client_cert' => $tls_client_cert, + 'tls_client_key' => $tls_client_key, + 'poller_id' => defined('POLLER_ID') ? (string) POLLER_ID : '', + 'tls_verify_peer' => true, 'tls_verify_peer_name' => true, - 'errors' => array_values(array_unique($errors)) - ); - $config['valid'] = empty($config['errors']); + 'errors' => array_values(array_unique($errors)) + ]; + $config['valid'] = empty($config['errors']); $config['fingerprint'] = audit_syslog_destination_fingerprint($config); return $config; } -function audit_syslog_destination_fingerprint($config) { - $identity = array( - 'receiver' => $config['receiver'], - 'port' => (int) $config['port'], - 'transport' => $config['transport'], - 'format' => $config['format'], - 'facility' => $config['facility'], - 'application' => $config['application'], - 'node_id' => $config['node_id'], - 'tls_ca_file' => $config['tls_ca_file'], +/** + * @param array $config + */ +function audit_syslog_destination_fingerprint(array $config): string { + $identity = [ + 'receiver' => $config['receiver'], + 'port' => (int) $config['port'], + 'transport' => $config['transport'], + 'format' => $config['format'], + 'facility' => $config['facility'], + 'application' => $config['application'], + 'node_id' => $config['node_id'], + 'tls_ca_file' => $config['tls_ca_file'], 'tls_client_cert' => $config['tls_client_cert'] - ); + ]; return hash('sha256', audit_json_encode($identity, JSON_UNESCAPED_SLASHES)); } -function audit_syslog_facilities() { - return array( - 'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3, - 'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7, - 'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11, - 'ntp' => 12, 'audit' => 13, 'alert' => 14, 'clock' => 15, +/** + * @return array + */ +function audit_syslog_facilities(): array { + return [ + 'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3, + 'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7, + 'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11, + 'ntp' => 12, 'audit' => 13, 'alert' => 14, 'clock' => 15, 'local0' => 16, 'local1' => 17, 'local2' => 18, 'local3' => 19, 'local4' => 20, 'local5' => 21, 'local6' => 22, 'local7' => 23 - ); + ]; } -function audit_syslog_severity_code($severity) { - $map = array( +function audit_syslog_severity_code(mixed $severity): int { + $map = [ 'emergency' => 0, 'emerg' => 0, 'alert' => 1, 'critical' => 2, - 'crit' => 2, 'error' => 3, 'err' => 3, 'warning' => 4, - 'warn' => 4, 'notice' => 5, 'info' => 6, 'debug' => 7 - ); + 'crit' => 2, 'error' => 3, 'err' => 3, 'warning' => 4, + 'warn' => 4, 'notice' => 5, 'info' => 6, 'debug' => 7 + ]; $severity = strtolower((string) $severity); return isset($map[$severity]) ? $map[$severity] : 6; } -function audit_syslog_header_token($value, $maximum, $fallback) { +function audit_syslog_header_token(mixed $value, int $maximum, string $fallback): string { $value = preg_replace('/[^\\x21-\\x3c\\x3e-\\x5a\\x5e-\\x7e]/', '_', (string) $value); - $value = substr($value, 0, $maximum); + $value = substr($value ?? '', 0, $maximum); return $value === '' ? $fallback : $value; } -function audit_syslog_structured_value($value) { +function audit_syslog_structured_value(mixed $value): string { $value = preg_replace('/[\\x00-\\x1f\\x7f]/', ' ', (string) $value); - return str_replace(array('\\', '"', ']'), array('\\\\', '\\"', '\\]'), $value); + return str_replace(['\\', '"', ']'], ['\\\\', '\\"', '\\]'], $value ?? ''); } -function audit_syslog_timestamp($value) { +function audit_syslog_timestamp(mixed $value): string { $value = (string) $value; + if (preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]([0-9]{2}:[0-9]{2}:[0-9]{2})(\\.[0-9]{1,6})?/', $value, $matches)) { return $matches[1] . 'T' . $matches[2] . (isset($matches[3]) ? $matches[3] : '') . 'Z'; } @@ -275,41 +302,47 @@ function audit_syslog_timestamp($value) { return gmdate('Y-m-d\\TH:i:s\\Z'); } -function audit_syslog_normalized_data($event, $config) { - $data = audit_external_event_data($event); - $data['node_id'] = $config['node_id']; +/** + * @param array $event + * @param array $config + * @return array + */ +function audit_syslog_normalized_data(array $event, array $config): array { + $data = audit_external_event_data($event); + $data['node_id'] = $config['node_id']; $data['poller_id'] = $config['poller_id'] !== '' ? $config['poller_id'] : null; return $data; } -function audit_syslog_cef_escape_header($value) { - return str_replace(array('\\', '|', "\r", "\n"), array('\\\\', '\\|', ' ', ' '), (string) $value); +function audit_syslog_cef_escape_header(mixed $value): string { + return str_replace(['\\', '|', "\r", "\n"], ['\\\\', '\\|', ' ', ' '], (string) $value); } -function audit_syslog_cef_escape_extension($value) { +function audit_syslog_cef_escape_extension(mixed $value): string { return str_replace( - array('\\', '=', "\r", "\n"), - array('\\\\', '\\=', '\\r', '\\n'), + ['\\', '=', "\r", "\n"], + ['\\\\', '\\=', '\\r', '\\n'], (string) $value ); } -function audit_syslog_cef_severity($severity) { - $map = array( +function audit_syslog_cef_severity(mixed $severity): int { + $map = [ 'emergency' => 10, 'emerg' => 10, 'alert' => 10, - 'critical' => 9, 'crit' => 9, 'error' => 8, 'err' => 8, - 'warning' => 6, 'warn' => 6, 'notice' => 5, - 'info' => 3, 'debug' => 1 - ); + 'critical' => 9, 'crit' => 9, 'error' => 8, 'err' => 8, + 'warning' => 6, 'warn' => 6, 'notice' => 5, + 'info' => 3, 'debug' => 1 + ]; $severity = strtolower((string) $severity); return isset($map[$severity]) ? $map[$severity] : 3; } -function audit_syslog_cef_event_field($value) { +function audit_syslog_cef_event_field(mixed $value): string { if (is_string($value) && $value !== '') { $decoded = audit_json_decode($value, $error); + if ($error === null) { $value = $decoded; } @@ -333,9 +366,13 @@ function audit_syslog_cef_event_field($value) { return audit_redact_sensitive_value((string) $value); } -function audit_syslog_cef_payload($event, $config) { +/** + * @param array $event + * @param array $config + */ +function audit_syslog_cef_payload(array $event, array $config): string { $severity = audit_syslog_cef_severity($event['severity'] ?? 'info'); - $header = array( + $header = [ 'CEF:0', 'Cacti', 'Audit Plugin', @@ -343,30 +380,30 @@ function audit_syslog_cef_payload($event, $config) { $event['event_type'] ?? 'cacti.audit', $event['action'] ?? 'audit', $severity - ); - $extension = array( + ]; + $extension = [ 'externalId' => $event['event_uuid'] ?? '', - 'suser' => $event['user_id'] ?? '', - 'src' => $event['ip_address'] ?? '', - 'act' => $event['action'] ?? '', - 'outcome' => $event['operation_outcome'] ?? '', - 'cs1Label' => 'Correlation ID', - 'cs1' => $event['correlation_id'] ?? '', - 'cs2Label' => 'Target', - 'cs2' => trim(($event['target_type'] ?? '') . ':' . ($event['target_id'] ?? ''), ':'), - 'cs3Label' => 'Node ID', - 'cs3' => $config['node_id'], - 'cn1Label' => 'Poller ID', - 'cn1' => $config['poller_id'], - 'cs4Label' => 'Submitted Data', - 'cs4' => audit_syslog_cef_event_field($event['post'] ?? ''), - 'cs5Label' => 'Object Data', - 'cs5' => audit_syslog_cef_event_field($event['object_data'] ?? ''), - 'cs6Label' => 'Details', - 'cs6' => audit_syslog_cef_event_field($event['details'] ?? '') - ); - $encoded_header = array_map('audit_syslog_cef_escape_header', $header); - $encoded_extension = array(); + 'suser' => $event['user_id'] ?? '', + 'src' => $event['ip_address'] ?? '', + 'act' => $event['action'] ?? '', + 'outcome' => $event['operation_outcome'] ?? '', + 'cs1Label' => 'Correlation ID', + 'cs1' => $event['correlation_id'] ?? '', + 'cs2Label' => 'Target', + 'cs2' => trim(($event['target_type'] ?? '') . ':' . ($event['target_id'] ?? ''), ':'), + 'cs3Label' => 'Node ID', + 'cs3' => $config['node_id'], + 'cn1Label' => 'Poller ID', + 'cn1' => $config['poller_id'], + 'cs4Label' => 'Submitted Data', + 'cs4' => audit_syslog_cef_event_field($event['post'] ?? ''), + 'cs5Label' => 'Object Data', + 'cs5' => audit_syslog_cef_event_field($event['object_data'] ?? ''), + 'cs6Label' => 'Details', + 'cs6' => audit_syslog_cef_event_field($event['details'] ?? '') + ]; + $encoded_header = array_map('audit_syslog_cef_escape_header', $header); + $encoded_extension = []; foreach ($extension as $name => $value) { $encoded_extension[] = $name . '=' . audit_syslog_cef_escape_extension($value); @@ -375,7 +412,11 @@ function audit_syslog_cef_payload($event, $config) { return implode('|', $encoded_header) . '|' . implode(' ', $encoded_extension); } -function audit_syslog_message_payload($event, $config) { +/** + * @param array $event + * @param array $config + */ +function audit_syslog_message_payload(array $event, array $config): string { if ($config['format'] === 'cef') { return audit_syslog_cef_payload($event, $config); } @@ -387,37 +428,42 @@ function audit_syslog_message_payload($event, $config) { return 'Audit event ' . (string) ($event['event_uuid'] ?? ''); } -function audit_syslog_record($event, $config) { +/** + * @param array $event + * @param array $config + * @return array + */ +function audit_syslog_record(array $event, array $config): array { if (empty($config['valid'])) { - return array( - 'status' => 'failed', - 'permanent' => true, + return [ + 'status' => 'failed', + 'permanent' => true, 'error_code' => 'configuration_invalid', - 'error' => implode(',', $config['errors']), - 'record' => '' - ); + 'error' => implode(',', $config['errors']), + 'record' => '' + ]; } $facilities = audit_syslog_facilities(); - $priority = ($facilities[$config['facility']] * 8) + + $priority = ($facilities[$config['facility']] * 8) + audit_syslog_severity_code($event['severity'] ?? 'info'); - $timestamp = audit_syslog_timestamp($event['event_time'] ?? ''); - $hostname = audit_syslog_header_token($config['node_id'], 255, 'cacti'); - $application = audit_syslog_header_token($config['application'], 48, 'cacti-audit'); - $poller_id = $config['poller_id'] !== '' ? $config['poller_id'] : '-'; - $process = audit_syslog_header_token($poller_id, 128, '-'); - $message_id = audit_syslog_header_token($event['event_type'] ?? 'cacti.audit', 32, 'cacti.audit'); - $structured_values = array( - 'eventUuid' => $event['event_uuid'] ?? '', + $timestamp = audit_syslog_timestamp($event['event_time'] ?? ''); + $hostname = audit_syslog_header_token($config['node_id'], 255, 'cacti'); + $application = audit_syslog_header_token($config['application'], 48, 'cacti-audit'); + $poller_id = $config['poller_id'] !== '' ? $config['poller_id'] : '-'; + $process = audit_syslog_header_token($poller_id, 128, '-'); + $message_id = audit_syslog_header_token($event['event_type'] ?? 'cacti.audit', 32, 'cacti.audit'); + $structured_values = [ + 'eventUuid' => $event['event_uuid'] ?? '', 'correlationId' => $event['correlation_id'] ?? '', - 'nodeId' => $config['node_id'], - 'pollerId' => $config['poller_id'], - 'outcome' => $event['operation_outcome'] ?? '', - 'targetType' => $event['target_type'] ?? '', - 'targetId' => $event['target_id'] ?? '', + 'nodeId' => $config['node_id'], + 'pollerId' => $config['poller_id'], + 'outcome' => $event['operation_outcome'] ?? '', + 'targetType' => $event['target_type'] ?? '', + 'targetId' => $event['target_id'] ?? '', 'integrityHash' => $event['integrity_hash'] ?? '' - ); - $structured = array(); + ]; + $structured = []; foreach ($structured_values as $name => $value) { $structured[] = $name . '="' . audit_syslog_structured_value($value) . '"'; @@ -429,35 +475,35 @@ function audit_syslog_record($event, $config) { audit_syslog_message_payload($event, $config); if (strlen($record) > 262144) { - return array( - 'status' => 'failed', - 'permanent' => true, + return [ + 'status' => 'failed', + 'permanent' => true, 'error_code' => 'message_too_large', - 'error' => 'The formatted Syslog record exceeds 262144 bytes.', - 'record' => '' - ); + 'error' => 'The formatted Syslog record exceeds 262144 bytes.', + 'record' => '' + ]; } if ($config['transport'] === 'udp' && strlen($record) > $config['udp_max_size']) { - return array( - 'status' => 'failed', - 'permanent' => true, + return [ + 'status' => 'failed', + 'permanent' => true, 'error_code' => 'udp_message_too_large', - 'error' => 'The formatted Syslog record exceeds the configured UDP maximum.', - 'record' => '' - ); + 'error' => 'The formatted Syslog record exceeds the configured UDP maximum.', + 'record' => '' + ]; } - return array( - 'status' => 'ready', - 'permanent' => false, + return [ + 'status' => 'ready', + 'permanent' => false, 'error_code' => '', - 'error' => '', - 'record' => $record - ); + 'error' => '', + 'record' => $record + ]; } -function audit_syslog_frame($record, $transport) { +function audit_syslog_frame(string $record, string $transport): string { if ($transport === 'tcp' || $transport === 'tls') { return strlen($record) . ' ' . $record; } @@ -465,8 +511,12 @@ function audit_syslog_frame($record, $transport) { return $record; } -function audit_syslog_socket_target($config) { +/** + * @param array $config + */ +function audit_syslog_socket_target(array $config): string { $receiver = $config['receiver']; + if (filter_var($receiver, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { $receiver = '[' . $receiver . ']'; } @@ -476,10 +526,11 @@ function audit_syslog_socket_target($config) { return $scheme . '://' . $receiver . ':' . $config['port']; } -function audit_syslog_stream_operation($operation, &$warning = null) { +function audit_syslog_stream_operation(callable $operation, string &$warning = ''): mixed { $warning = ''; - $handler = function($severity, $message) use (&$warning) { + $handler = function ($severity, $message) use (&$warning) { $warning = audit_syslog_bounded_error($message); + return true; }; @@ -489,24 +540,29 @@ function audit_syslog_stream_operation($operation, &$warning = null) { return call_user_func($operation); } catch (Throwable $exception) { $warning = audit_syslog_bounded_error($exception->getMessage()); + return false; } finally { restore_error_handler(); } } -function audit_syslog_open_socket($config) { - $context_options = array(); +/** + * @param array $config + * @return array + */ +function audit_syslog_open_socket(array $config): array { + $context_options = []; if ($config['transport'] === 'tls') { - $context_options['ssl'] = array( - 'verify_peer' => true, - 'verify_peer_name' => true, - 'allow_self_signed' => false, - 'peer_name' => $config['receiver'], - 'SNI_enabled' => true, + $context_options['ssl'] = [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'allow_self_signed' => false, + 'peer_name' => $config['receiver'], + 'SNI_enabled' => true, 'disable_compression' => true - ); + ]; if ($config['tls_ca_file'] !== '') { $context_options['ssl']['cafile'] = $config['tls_ca_file']; @@ -514,16 +570,16 @@ function audit_syslog_open_socket($config) { if ($config['tls_client_cert'] !== '') { $context_options['ssl']['local_cert'] = $config['tls_client_cert']; - $context_options['ssl']['local_pk'] = $config['tls_client_key']; + $context_options['ssl']['local_pk'] = $config['tls_client_key']; } } - $context = stream_context_create($context_options); - $error_number = 0; - $error_message = ''; - $flags = STREAM_CLIENT_CONNECT; + $context = stream_context_create($context_options); + $error_number = 0; + $error_message = ''; + $flags = STREAM_CLIENT_CONNECT; $stream_warning = ''; - $socket = audit_syslog_stream_operation(function() use ( + $socket = audit_syslog_stream_operation(function () use ( $config, &$error_number, &$error_message, @@ -545,45 +601,49 @@ function audit_syslog_open_socket($config) { ? $error_message : ($stream_warning !== '' ? $stream_warning : 'Unable to connect to Syslog receiver.'); - return array( - 'socket' => null, + return [ + 'socket' => null, 'error_code' => 'connection_failed', - 'error' => audit_syslog_bounded_error($error) - ); + 'error' => audit_syslog_bounded_error($error) + ]; } stream_set_timeout($socket, $config['timeout']); stream_set_blocking($socket, true); - return array('socket' => $socket, 'error_code' => '', 'error' => ''); + return ['socket' => $socket, 'error_code' => '', 'error' => '']; } -function audit_syslog_bounded_error($error) { +function audit_syslog_bounded_error(mixed $error): string { $error = preg_replace('/[\\x00-\\x1f\\x7f]+/', ' ', (string) $error); - return substr(trim($error), 0, 1024); + return substr(trim($error ?? ''), 0, 1024); } -function audit_syslog_fwrite($socket, $message, &$warning = null) { - return audit_syslog_stream_operation(function() use ($socket, $message) { +function audit_syslog_fwrite(mixed $socket, string $message, string &$warning = ''): int|false { + return audit_syslog_stream_operation(function () use ($socket, $message) { return fwrite($socket, $message); }, $warning); } -function audit_syslog_write($socket, $message, $transport) { +/** + * @return array + */ +function audit_syslog_write(mixed $socket, string $message, string $transport): array { if (!is_resource($socket)) { - return array('status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.'); + return ['status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.']; } if ($transport === 'udp') { $stream_warning = ''; - $written = audit_syslog_fwrite($socket, $message, $stream_warning); + $written = audit_syslog_fwrite($socket, $message, $stream_warning); + if ($written !== strlen($message)) { $error = $stream_warning !== '' ? $stream_warning : 'Unable to write the complete Syslog datagram.'; - return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => $error); + return ['status' => 'failed', 'error_code' => 'write_failed', 'error' => $error]; } } else { $length = strlen($message); @@ -591,45 +651,53 @@ function audit_syslog_write($socket, $message, $transport) { while ($offset < $length) { $stream_warning = ''; - $written = audit_syslog_fwrite($socket, substr($message, $offset), $stream_warning); + $written = audit_syslog_fwrite($socket, substr($message, $offset), $stream_warning); + if ($written === false || $written === 0) { $metadata = stream_get_meta_data($socket); - $error = !empty($metadata['timed_out']) + $error = !empty($metadata['timed_out']) ? 'Syslog write timed out.' : ($stream_warning !== '' ? $stream_warning : 'Unable to write the complete Syslog record.'); - return array('status' => 'failed', 'error_code' => 'write_failed', 'error' => $error); + return ['status' => 'failed', 'error_code' => 'write_failed', 'error' => $error]; } $offset += $written; } } - return array('status' => 'sent_unconfirmed', 'error_code' => '', 'error' => ''); + return ['status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '']; } -function audit_syslog_send_event($event, $config, &$socket = null) { +/** + * @param array $event + * @param array $config + * @return array + */ +function audit_syslog_send_event(array $event, array $config, mixed &$socket = null): array { $formatted = audit_syslog_record($event, $config); + if ($formatted['status'] !== 'ready') { return $formatted; } if (!is_resource($socket)) { $opened = audit_syslog_open_socket($config); + if (!is_resource($opened['socket'])) { - return array( - 'status' => 'failed', - 'permanent' => false, + return [ + 'status' => 'failed', + 'permanent' => false, 'error_code' => $opened['error_code'], - 'error' => $opened['error'] - ); + 'error' => $opened['error'] + ]; } $socket = $opened['socket']; } - $message = audit_syslog_frame($formatted['record'], $config['transport']); - $result = audit_syslog_write($socket, $message, $config['transport']); + $message = audit_syslog_frame($formatted['record'], $config['transport']); + $result = audit_syslog_write($socket, $message, $config['transport']); $result['permanent'] = false; if ($result['status'] !== 'sent_unconfirmed' && is_resource($socket)) { @@ -640,35 +708,42 @@ function audit_syslog_send_event($event, $config, &$socket = null) { return $result; } -function audit_enqueue_syslog_event($audit_id) { - if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) { +function audit_enqueue_syslog_event(int $audit_id): void { + if (!audit_syslog_enabled() || + !db_table_exists('audit_log') || + !db_table_exists('audit_syslog_delivery')) { return; } $event = db_fetch_row_prepared('SELECT id, event_uuid, request_status FROM audit_log WHERE id = ?', - array($audit_id)); + [$audit_id]); - if (!cacti_sizeof($event) || $event['request_status'] === 'started' || $event['event_uuid'] === '') { + if (!is_array($event) || $event['request_status'] === 'started' || $event['event_uuid'] === '') { return; } $config = audit_syslog_config(); - $state = $config['valid'] ? 'pending' : 'dead_letter'; - $error = $config['valid'] ? null : audit_syslog_bounded_error('configuration_invalid: ' . implode(',', $config['errors'])); + $state = $config['valid'] ? 'pending' : 'dead_letter'; + $error = $config['valid'] ? null : audit_syslog_bounded_error('configuration_invalid: ' . implode(',', $config['errors'])); db_execute_prepared('INSERT IGNORE INTO audit_syslog_delivery ( audit_id, event_uuid, destination_fingerprint, node_id, poller_id, state, attempts, next_attempt, last_error, created_time, updated_time ) VALUES (?, ?, ?, ?, ?, ?, 0, UTC_TIMESTAMP(6), ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))', - array( + [ $event['id'], $event['event_uuid'], $config['fingerprint'], $config['node_id'], $config['poller_id'], $state, $error - )); + ]); } -function audit_syslog_delivery_config($config, $delivery) { +/** + * @param array $config + * @param array $delivery + * @return array + */ +function audit_syslog_delivery_config(array $config, array $delivery): array { if (isset($delivery['delivery_node_id']) && $delivery['delivery_node_id'] !== '') { $config['node_id'] = $delivery['delivery_node_id']; } @@ -682,16 +757,24 @@ function audit_syslog_delivery_config($config, $delivery) { return $config; } -function audit_syslog_retry_delay($attempt, $config) { +/** + * @param array $config + */ +function audit_syslog_retry_delay(mixed $attempt, array $config): int { $exponent = min(max(0, (int) $attempt - 1), 30); - $delay = $config['retry_base'] * pow(2, $exponent); + $delay = $config['retry_base'] * pow(2, $exponent); return (int) min($config['retry_max'], $delay); } -function audit_syslog_update_delivery($delivery, $result, $config) { +/** + * @param array $delivery + * @param array $result + * @param array $config + */ +function audit_syslog_update_delivery(array $delivery, array $result, array $config): void { $attempts = (int) $delivery['attempts'] + 1; - $error = isset($result['error']) ? audit_syslog_bounded_error($result['error']) : ''; + $error = isset($result['error']) ? audit_syslog_bounded_error($result['error']) : ''; if ($result['status'] === 'sent_unconfirmed') { db_execute_prepared("UPDATE audit_syslog_delivery @@ -703,14 +786,15 @@ function audit_syslog_update_delivery($delivery, $result, $config) { last_error = NULL, updated_time = UTC_TIMESTAMP(6) WHERE id = ?", - array($config['fingerprint'], $attempts, $delivery['delivery_id'])); + [$config['fingerprint'], $attempts, $delivery['delivery_id']]); + return; } - $permanent = !empty($result['permanent']) || $attempts >= $config['max_attempts']; - $state = $permanent ? 'dead_letter' : 'retry'; - $delay = $permanent ? 0 : audit_syslog_retry_delay($attempts, $config); - $error_code = isset($result['error_code']) ? $result['error_code'] : 'delivery_failed'; + $permanent = !empty($result['permanent']) || $attempts >= $config['max_attempts']; + $state = $permanent ? 'dead_letter' : 'retry'; + $delay = $permanent ? 0 : audit_syslog_retry_delay($attempts, $config); + $error_code = isset($result['error_code']) ? $result['error_code'] : 'delivery_failed'; $stored_error = audit_syslog_bounded_error($error_code . ': ' . $error); db_execute_prepared('UPDATE audit_syslog_delivery @@ -722,17 +806,19 @@ function audit_syslog_update_delivery($delivery, $result, $config) { last_error = ?, updated_time = UTC_TIMESTAMP(6) WHERE id = ?', - array($state, $config['fingerprint'], $attempts, $delay, $delay, $stored_error, $delivery['delivery_id'])); + [$state, $config['fingerprint'], $attempts, $delay, $delay, $stored_error, $delivery['delivery_id']]); } -function audit_process_syslog_queue() { +function audit_process_syslog_queue(): void { if (!audit_syslog_enabled() || !db_table_exists('audit_syslog_delivery')) { return; } $config = audit_syslog_config(); + if (!$config['valid']) { audit_syslog_check_health($config); + return; } @@ -749,16 +835,18 @@ function audit_process_syslog_queue() { AND a.request_status <> 'started' ORDER BY d.next_attempt, d.id LIMIT " . $batch_size, - array()); + []); $socket = null; - foreach ($deliveries as $delivery) { - $delivery_config = audit_syslog_delivery_config($config, $delivery); - $result = audit_syslog_send_event($delivery, $delivery_config, $socket); - audit_syslog_update_delivery($delivery, $result, $delivery_config); + if (is_array($deliveries)) { + foreach ($deliveries as $delivery) { + $delivery_config = audit_syslog_delivery_config($config, $delivery); + $result = audit_syslog_send_event($delivery, $delivery_config, $socket); + audit_syslog_update_delivery($delivery, $result, $delivery_config); - if ($result['status'] !== 'sent_unconfirmed' && empty($result['permanent'])) { - break; + if ($result['status'] !== 'sent_unconfirmed' && empty($result['permanent'])) { + break; + } } } @@ -769,13 +857,16 @@ function audit_process_syslog_queue() { audit_syslog_check_health($config); } -function audit_syslog_health() { +/** + * @return array + */ +function audit_syslog_health(): array { if (!db_table_exists('audit_syslog_delivery')) { - return array( - 'pending' => 0, 'retry' => 0, 'sent_unconfirmed' => 0, - 'dead_letter' => 0, 'oldest_pending_seconds' => 0, + return [ + 'pending' => 0, 'retry' => 0, 'sent_unconfirmed' => 0, + 'dead_letter' => 0, 'oldest_pending_seconds' => 0, 'last_attempt' => null, 'last_sent' => null, 'last_error' => null - ); + ]; } $row = db_fetch_row("SELECT @@ -795,29 +886,32 @@ function audit_syslog_health() { ORDER BY last_attempt DESC, id DESC LIMIT 1"); - return array( - 'pending' => (int) ($row['pending'] ?? 0), - 'retry' => (int) ($row['retry'] ?? 0), - 'sent_unconfirmed' => (int) ($row['sent_unconfirmed'] ?? 0), - 'dead_letter' => (int) ($row['dead_letter'] ?? 0), + return [ + 'pending' => (int) ($row['pending'] ?? 0), + 'retry' => (int) ($row['retry'] ?? 0), + 'sent_unconfirmed' => (int) ($row['sent_unconfirmed'] ?? 0), + 'dead_letter' => (int) ($row['dead_letter'] ?? 0), 'oldest_pending_seconds' => (int) ($row['oldest_pending_seconds'] ?? 0), - 'last_attempt' => $row['last_attempt'] ?? null, - 'last_sent' => $row['last_sent'] ?? null, - 'last_error' => $last_error !== false && $last_error !== '' ? $last_error : null - ); + 'last_attempt' => $row['last_attempt'] ?? null, + 'last_sent' => $row['last_sent'] ?? null, + 'last_error' => $last_error !== false && $last_error !== '' ? $last_error : null + ]; } -function audit_syslog_check_health($config = null) { +/** + * @param array|null $config + */ +function audit_syslog_check_health(?array $config = null): void { if (!audit_syslog_enabled()) { return; } - $config = $config === null ? audit_syslog_config() : $config; - $health = audit_syslog_health(); + $config = $config === null ? audit_syslog_config() : $config; + $health = audit_syslog_health(); $unhealthy = !$config['valid'] || $health['dead_letter'] >= $config['dead_letter_warning'] || $health['oldest_pending_seconds'] >= $config['pending_age_warning']; - $state = $unhealthy ? 'unhealthy' : 'healthy'; + $state = $unhealthy ? 'unhealthy' : 'healthy'; $previous = read_config_option('audit_syslog_health_state'); if ($previous !== $state) { @@ -829,19 +923,25 @@ function audit_syslog_check_health($config = null) { } } -function audit_syslog_retry_dead_letters($delivery_ids = array()) { +/** + * @param array $delivery_ids + */ +function audit_syslog_retry_dead_letters(array $delivery_ids = []): int { if (!db_table_exists('audit_syslog_delivery')) { return 0; } - $normalized_ids = array(); + $normalized_ids = []; + foreach ($delivery_ids as $delivery_id) { $delivery_id = (int) $delivery_id; + if ($delivery_id > 0) { $normalized_ids[] = $delivery_id; } } $delivery_ids = array_values(array_unique($normalized_ids)); + if (cacti_sizeof($delivery_ids) > 1000) { $delivery_ids = array_slice($delivery_ids, 0, 1000); } @@ -870,24 +970,27 @@ function audit_syslog_retry_dead_letters($delivery_ids = array()) { return db_affected_rows(); } -function audit_syslog_test_delivery() { +/** + * @return array + */ +function audit_syslog_test_delivery(): array { $config = audit_syslog_config(); - $event = array( - 'event_uuid' => audit_uuid_v4(), - 'correlation_id' => audit_request_correlation_id(), - 'event_type' => 'audit.syslog.test', - 'event_category' => 'audit', - 'severity' => 'notice', - 'user_id' => $_SESSION['sess_user_id'] ?? 0, - 'action' => 'test', - 'request_status' => 'completed', + $event = [ + 'event_uuid' => audit_uuid_v4(), + 'correlation_id' => audit_request_correlation_id(), + 'event_type' => 'audit.syslog.test', + 'event_category' => 'audit', + 'severity' => 'notice', + 'user_id' => $_SESSION['sess_user_id'] ?? 0, + 'action' => 'test', + 'request_status' => 'completed', 'operation_outcome' => 'success', - 'target_type' => 'syslog_receiver', - 'target_id' => $config['receiver'], - 'ip_address' => function_exists('get_client_addr') ? get_client_addr() : '', - 'event_time' => audit_utc_time(), - 'details' => audit_json_encode(array('test' => true)) - ); + 'target_type' => 'syslog_receiver', + 'target_id' => $config['receiver'], + 'ip_address' => function_exists('get_client_addr') ? get_client_addr() : '', + 'event_time' => audit_utc_time(), + 'details' => audit_json_encode(['test' => true]) + ]; $socket = null; $result = audit_syslog_send_event($event, $config, $socket); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c3b0c27 --- /dev/null +++ b/composer.json @@ -0,0 +1,48 @@ +{ + "name": "cacti/plugin-audit", + "description": "Cacti Audit Plugin - logs GUI and CLI activities to an audit trail.", + "type": "cacti-plugin", + "license": "GPL-2.0-only", + "require": { + "php": "^8.1", + "ext-json": "*", + "ext-mbstring": "*", + "ext-sockets": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.86", + "pestphp/pest": "^2.0", + "phpstan/phpstan": "^2.2", + "symfony/console": "^7.0", + "symfony/finder": "^7.0", + "symfony/process": "^7.0", + "symfony/string": "^7.0", + "symfony/event-dispatcher": "^7.0", + "symfony/filesystem": "^7.0", + "symfony/options-resolver": "^7.0", + "symfony/stopwatch": "^7.0" + }, + "scripts": { + "php-cs-fixer": "php-cs-fixer fix --dry-run --diff --config=./.php-cs-fixer.php", + "php-cs-fixit": "php-cs-fixer fix --config=./.php-cs-fixer.php", + "phpstan": "phpstan analyse --memory-limit=512M", + "test": "pest" + }, + "authors": [ + { + "name": "The Cacti Group", + "email": "developers@cacti.net" + } + ], + "config": { + "sort-packages": true, + "platform": { + "php": "8.2" + }, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..66cad06 --- /dev/null +++ b/composer.lock @@ -0,0 +1,5488 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "6c09b532f263ca1e3da868f938106757", + "packages": [], + "packages-dev": [ + { + "name": "brianium/paratest", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "633c0987ecf6d9b057431225da37b088aa9274a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/633c0987ecf6d9b057431225da37b088aa9274a5", + "reference": "633c0987ecf6d9b057431225da37b088aa9274a5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.2.0", + "jean85/pretty-package-versions": "^2.0.6", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-timer": "^6.0.0", + "phpunit/phpunit": "^10.5.47", + "sebastian/environment": "^6.1.0", + "symfony/console": "^6.4.7 || ^7.1.5", + "symfony/process": "^6.4.7 || ^7.1.5" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0.0", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^1.12.6", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.1", + "squizlabs/php_codesniffer": "^3.10.3", + "symfony/filesystem": "^6.4.3 || ^7.1.5" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2025-06-25T06:09:59+00:00" + }, + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.17", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "0ee88422118f3cc59c8c3def222ba7f1493b6d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/0ee88422118f3cc59c8c3def222ba7f1493b6d5b", + "reference": "0ee88422118f3cc59c8c3def222ba7f1493b6d5b", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.17" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-07-24T13:54:39+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.5.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "f5c101b929c958e849a633283adff296ed5f38f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f5c101b929c958e849a633283adff296ed5f38f5", + "reference": "f5c101b929c958e849a633283adff296ed5f38f5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.16.0", + "nunomaduro/termwind": "^2.1.0", + "php": "^8.2.0", + "symfony/console": "^7.1.5" + }, + "conflict": { + "laravel/framework": "<11.0.0 || >=12.0.0", + "phpunit/phpunit": "<10.5.1 || >=12.0.0" + }, + "require-dev": { + "larastan/larastan": "^2.9.8", + "laravel/framework": "^11.28.0", + "laravel/pint": "^1.18.1", + "laravel/sail": "^1.36.0", + "laravel/sanctum": "^4.0.3", + "laravel/tinker": "^2.10.0", + "orchestra/testbench-core": "^9.5.3", + "pestphp/pest": "^2.36.0 || ^3.4.0", + "sebastian/environment": "^6.1.0 || ^7.2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2024-10-15T16:06:32+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "pestphp/pest", + "version": "v2.36.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest.git", + "reference": "d66361b272ae4ee4bc33accb5ea3ff385b92e9e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest/zipball/d66361b272ae4ee4bc33accb5ea3ff385b92e9e1", + "reference": "d66361b272ae4ee4bc33accb5ea3ff385b92e9e1", + "shasum": "" + }, + "require": { + "brianium/paratest": "^7.4.9", + "nunomaduro/collision": "^7.11.0|^8.5.0", + "nunomaduro/termwind": "^1.16.0|^2.3.3", + "pestphp/pest-plugin": "^2.1.1", + "pestphp/pest-plugin-arch": "^2.7.0", + "php": "^8.2.0", + "phpunit/phpunit": "^10.5.63" + }, + "conflict": { + "filp/whoops": "<2.16.0", + "phpunit/phpunit": ">10.5.63", + "sebastian/exporter": "<5.1.0", + "webmozart/assert": "<1.11.0" + }, + "require-dev": { + "pestphp/pest-dev-tools": "^2.17.0", + "pestphp/pest-plugin-type-coverage": "^2.8.7", + "symfony/process": "^6.4.0|^7.4.4" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Plugins\\Bail", + "Pest\\Plugins\\Cache", + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Environment", + "Pest\\Plugins\\Help", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Only", + "Pest\\Plugins\\Printer", + "Pest\\Plugins\\ProcessIsolation", + "Pest\\Plugins\\Profile", + "Pest\\Plugins\\Retry", + "Pest\\Plugins\\Snapshot", + "Pest\\Plugins\\Verbose", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Parallel" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "files": [ + "src/Functions.php", + "src/Pest.php" + ], + "psr-4": { + "Pest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "The elegant PHP Testing Framework.", + "keywords": [ + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "issues": "https://github.com/pestphp/pest/issues", + "source": "https://github.com/pestphp/pest/tree/v2.36.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-01-28T02:02:41+00:00" + }, + { + "name": "pestphp/pest-plugin", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "e05d2859e08c2567ee38ce8b005d044e72648c0b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/e05d2859e08c2567ee38ce8b005d044e72648c0b", + "reference": "e05d2859e08c2567ee38ce8b005d044e72648c0b", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "composer-runtime-api": "^2.2.2", + "php": "^8.1" + }, + "conflict": { + "pestphp/pest": "<2.2.3" + }, + "require-dev": { + "composer/composer": "^2.5.8", + "pestphp/pest": "^2.16.0", + "pestphp/pest-dev-tools": "^2.16.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager" + }, + "autoload": { + "psr-4": { + "Pest\\Plugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest plugin manager", + "keywords": [ + "framework", + "manager", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin/tree/v2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2023-08-22T08:40:06+00:00" + }, + { + "name": "pestphp/pest-plugin-arch", + "version": "v2.7.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-arch.git", + "reference": "d23b2d7498475354522c3818c42ef355dca3fcda" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/d23b2d7498475354522c3818c42ef355dca3fcda", + "reference": "d23b2d7498475354522c3818c42ef355dca3fcda", + "shasum": "" + }, + "require": { + "nunomaduro/collision": "^7.10.0|^8.1.0", + "pestphp/pest-plugin": "^2.1.1", + "php": "^8.1", + "ta-tikoma/phpunit-architecture-test": "^0.8.4" + }, + "require-dev": { + "pestphp/pest": "^2.33.0", + "pestphp/pest-dev-tools": "^2.16.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Arch\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Arch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Arch plugin for Pest PHP.", + "keywords": [ + "arch", + "architecture", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v2.7.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2024-01-26T09:46:42+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.5", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-07-05T06:31:06+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.63", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "33198268dad71e926626b618f3ec3966661e4d90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:48:37+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T11:50:14+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-06T11:10:32+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-11T16:38:44+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "13b38720174286f55d1761152b575a8d1436fc25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T08:31:18+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T15:23:29+00:00" + }, + { + "name": "ta-tikoma/phpunit-architecture-test", + "version": "0.8.7", + "source": { + "type": "git", + "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18.0 || ^5.0.0", + "php": "^8.1.0", + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "require-dev": { + "laravel/pint": "^1.13.7", + "phpstan/phpstan": "^1.10.52" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPUnit\\Architecture\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ni Shi", + "email": "futik0ma011@gmail.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Methods for testing application architecture", + "keywords": [ + "architecture", + "phpunit", + "stucture", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" + }, + "time": "2026-02-17T17:25:14+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.1", + "ext-json": "*", + "ext-mbstring": "*", + "ext-sockets": "*" + }, + "platform-dev": [], + "platform-overrides": { + "php": "8.2" + }, + "plugin-api-version": "2.6.0" +} diff --git a/index.php b/index.php index 2e22b8c..828ecbe 100644 --- a/index.php +++ b/index.php @@ -22,4 +22,4 @@ +-------------------------------------------------------------------------+ */ -header("Location:../index.php"); +header('Location:../index.php'); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..2170645 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,997 @@ +parameters: + ignoreErrors: + - + message: '#^Call to function cacti_log\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: audit.php + + - + message: '#^Call to function cacti_log\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 4 + path: audit_functions.php + + - + message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 3 + path: audit_functions.php + + - + message: '#^Call to function cacti_log\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: audit_syslog.php + + - + message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 1 + path: audit_syslog.php + + - + message: '#^Call to function cacti_log\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 4 + path: setup.php + + - + message: '#^Call to function set_config_option\(\) on a separate line has no effect\.$#' + identifier: function.resultUnused + count: 2 + path: setup.php + + - + message: '#^Parameter \#1 \$filename of function file_get_contents expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/Php81SyntaxTest.php + + - + message: '#^Parameter \#1 \$filename of function is_readable expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/Php81SyntaxTest.php + + - + message: '#^Parameter \#2 \$subject of function preg_match expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/Php81SyntaxTest.php + + - + message: '#^Parameter \#1 \$filename of function file_get_contents expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/PreparedStatementConsistencyTest.php + + - + message: '#^Parameter \#2 \$subject of function preg_match expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/PreparedStatementConsistencyTest.php + + - + message: '#^Cannot access offset ''info'' on array\|false\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: tests/Security/SetupStructureTest.php + + - + message: '#^Parameter \#1 \$filename of function file_get_contents expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/SetupStructureTest.php + + - + message: '#^Parameter \#1 \$filename of function parse_ini_file expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/Security/SetupStructureTest.php + + - + message: '#^Call to function in_array\(\) with arguments ''old'', array\{\} and true will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: tests/auth_audit_test.php + + - + message: '#^Empty array passed to foreach\.$#' + identifier: foreach.emptyArray + count: 1 + path: tests/auth_audit_test.php + + - + message: '#^Offset ''audit_brute_force…'' on array\{audit_brute_force_last_alert\: ''''\} on left side of \?\? always exists and is not nullable\.$#' + identifier: nullCoalesce.offset + count: 1 + path: tests/auth_audit_test.php + + - + message: '#^Offset 0 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 9 + path: tests/auth_audit_test.php + + - + message: '#^Offset 1 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 2 + path: tests/auth_audit_test.php + + - + message: '#^Offset 3 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 2 + path: tests/auth_audit_test.php + + - + message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|false given\.$#' + identifier: argument.type + count: 4 + path: tests/auth_audit_test.php + + - + message: '#^Function __\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __\(\) has parameter \$domain with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __\(\) has parameter \$text with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __esc\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __esc\(\) has parameter \$domain with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function __esc\(\) has parameter \$text with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_add_column\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_add_column\(\) has parameter \$data with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_add_column\(\) has parameter \$plugin with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_add_column\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_table_create\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_table_create\(\) has parameter \$data with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_table_create\(\) has parameter \$plugin with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function api_plugin_db_table_create\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has parameter \$also_print with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has parameter \$level with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has parameter \$log_type with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_log\(\) has parameter \$message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_sizeof\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function cacti_sizeof\(\) has parameter \$array with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_affected_rows\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_column_exists\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_column_exists\(\) has parameter \$column with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_column_exists\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_execute_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_fetch_row_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_index_exists\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_index_exists\(\) has parameter \$index with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_index_exists\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_table_exists\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function db_table_exists\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$error with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$optional with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$regex with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function form_input_validate\(\) has parameter \$value with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_filter_request_var\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_filter_request_var\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_nfilter_request_var\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_nfilter_request_var\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_request_var\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function get_request_var\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function html_escape\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function html_escape\(\) has parameter \$string with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function is_error_message\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function is_realm_allowed\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function is_realm_allowed\(\) has parameter \$realm with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function raise_message\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function raise_message\(\) has parameter \$id with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function raise_message\(\) has parameter \$level with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function raise_message\(\) has parameter \$text with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function read_config_option\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function read_config_option\(\) has parameter \$force with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function read_config_option\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function set_config_option\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function set_config_option\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function set_config_option\(\) has parameter \$value with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function sql_save\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function sql_save\(\) has parameter \$array with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function sql_save\(\) has parameter \$key with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Function sql_save\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/bootstrap.php + + - + message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|false given\.$#' + identifier: argument.type + count: 12 + path: tests/controller_security_test.php + + - + message: '#^Parameter \#1 \$haystack of function substr_count expects string, string\|false given\.$#' + identifier: argument.type + count: 2 + path: tests/controller_security_test.php + + - + message: '#^Function api_plugin_user_realm_auth\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function api_plugin_user_realm_auth\(\) has parameter \$filename with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function audit_test_assert_same\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function audit_test_assert_same\(\) has parameter \$actual with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function audit_test_assert_same\(\) has parameter \$expected with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function audit_test_assert_same\(\) has parameter \$message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_assoc_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Function db_fetch_cell_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Offset ''type'' might not exist on array\\|null\.$#' + identifier: offsetAccess.notFound + count: 1 + path: tests/security_functions_test.php + + - + message: '#^Cannot access offset 0 on array\\|false\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 9 + path: tests/syslog_functions_test.php + + - + message: '#^Cannot access offset 1 on array\\|false\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 4 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_assert\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_assert\(\) has parameter \$condition with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_assert\(\) has parameter \$message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_config\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_config\(\) has parameter \$overrides with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_event\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_remove_tls_material\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_remove_tls_material\(\) has parameter \$material with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_tls_material\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_tls_server\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_tls_server\(\) has parameter \$material with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_tls_server\(\) has parameter \$port with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_udp_server\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_udp_server\(\) has parameter \$error with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_udp_server\(\) has parameter \$error_message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_syslog_test_udp_server\(\) has parameter \$port with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$csr of function openssl_csr_sign expects OpenSSLCertificateSigningRequest\|string, OpenSSLCertificateSigningRequest\|true given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$haystack of function strrpos expects string, string\|false given\.$#' + identifier: argument.type + count: 3 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$socket of function socket_set_option expects Socket, Socket\|false given\.$#' + identifier: argument.type + count: 2 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$socket of function stream_socket_accept expects resource, resource\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$socket of function stream_socket_get_name expects resource, resource\|false given\.$#' + identifier: argument.type + count: 2 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$stream of function fclose expects resource, resource\|false given\.$#' + identifier: argument.type + count: 3 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$stream of function fread expects resource, resource\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$stream of function stream_set_timeout expects resource, resource\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_functions_test.php + + - + message: '#^Parameter \#1 \$string of function substr expects string, string\|false given\.$#' + identifier: argument.type + count: 3 + path: tests/syslog_functions_test.php + + - + message: '#^Function audit_queue_assert\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function audit_queue_assert\(\) has parameter \$condition with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function audit_queue_assert\(\) has parameter \$message with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function cacti_sizeof\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function cacti_sizeof\(\) has parameter \$value with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_affected_rows\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_execute_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_fetch_row_prepared\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_fetch_row_prepared\(\) has parameter \$params with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_fetch_row_prepared\(\) has parameter \$sql with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_table_exists\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function db_table_exists\(\) has parameter \$table with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function read_config_option\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Function read_config_option\(\) has parameter \$name with no type specified\.$#' + identifier: missingType.parameter + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Offset 0 does not exist on array\{\}\.$#' + identifier: offsetAccess.notFound + count: 15 + path: tests/syslog_queue_test.php + + - + message: '#^Parameter \#1 \$delivery_ids of function audit_syslog_retry_dead_letters expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_queue_test.php + + - + message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|false given\.$#' + identifier: argument.type + count: 1 + path: tests/syslog_queue_test.php \ No newline at end of file diff --git a/phpstan/stubs/cacti.stubs.php b/phpstan/stubs/cacti.stubs.php new file mode 100644 index 0000000..cc7c8bd --- /dev/null +++ b/phpstan/stubs/cacti.stubs.php @@ -0,0 +1,328 @@ + */ +global $config; + +/** @var string */ +global $database_default; + +/** @var string */ +global $action; + +/** @var array */ +global $utilities; + +/** @var array */ +global $menu; + +/** @var array */ +global $messages; + +/** @var array */ +global $audit_retentions; + +/** @var array */ +global $tabs; + +/** @var array */ +global $settings; + +/** @var array */ +global $item_rows; + +// ----- Plugin / Hook API --------------------------------------------- + +function api_plugin_register_hook(string $plugin, string $hook, string $function, string $file, int $install = 0): void { +} + +function api_plugin_register_realm(string $plugin, array|string $file, array|string $display, int $install = 0): void { +} + +function api_plugin_is_enabled(string $plugin): bool { + return false; +} + +function api_plugin_enable_hooks(string $plugin): void { +} + +function api_plugin_replicate_config(): void { +} + +function api_plugin_user_realm_auth(string $file = ''): bool { + return false; +} + +function auth_augment_roles(string $label, array $files): void { +} + +// ----- Database ------------------------------------------------------- + +function db_execute(string $sql, bool $log = true, mixed $db_conn = false): bool { + return false; +} + +function db_execute_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false, bool $execute_prepared = true): bool { + return false; +} + +/** + * @return array>|false + */ +function db_fetch_assoc(string $sql, bool $log = true, mixed $db_conn = false): array|false { + return false; +} + +/** + * @return array>|false + */ +function db_fetch_assoc_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false): array|false { + return false; +} + +/** + * @return array|false + */ +function db_fetch_row(string $sql, bool $log = true, mixed $db_conn = false): array|false { + return false; +} + +/** + * @return array|false + */ +function db_fetch_row_prepared(string $sql, array $params = [], bool $log = true, mixed $db_conn = false): array|false { + return false; +} + +function db_fetch_cell(string $sql, string $col_name = '', bool $log = true, mixed $db_conn = false): mixed { + return false; +} + +function db_fetch_cell_prepared(string $sql, array $params = [], string $col_name = '', bool $log = true, mixed $db_conn = false): mixed { + return false; +} + +function db_fetch_insert_id(string $table = ''): int { + return 0; +} + +function db_affected_rows(mixed $db_conn = false): int { + return 0; +} + +function db_column_exists(string $table, string $column, bool $log = true, mixed $db_conn = false): bool { + return false; +} + +function db_table_exists(string $table, bool $log = true, mixed $db_conn = false): bool { + return false; +} + +function db_index_exists(string $table, string $index, bool $log = true, mixed $db_conn = false): bool { + return false; +} + +function db_add_index(string $table, string $type, string $name, mixed $definition, bool $log = true, mixed $db_conn = false): void { +} + +// ----- Config / Options --------------------------------------------- + +function read_config_option(string $name, bool $global = false): mixed { + return false; +} + +function set_config_option(string $name, string $value): bool { + return false; +} + +// ----- Localization -------------------------------------------------- + +/** + * @param mixed ...$args sprintf arguments or domain + */ +function __(string $text, mixed ...$args): string { + return $text; +} + +/** + * @param mixed ...$args sprintf arguments or domain + */ +function __esc(string $text, mixed ...$args): string { + return $text; +} + +// ----- Input Handling ------------------------------------------------ + +function get_request_var(string $name, mixed $default = '', string $filter = ''): mixed { + return $default; +} + +function get_filter_request_var(string $name, int $filter = FILTER_DEFAULT, array $options = []): mixed { + return false; +} + +function get_nfilter_request_var(string $name, mixed $default = '', string $filter = ''): mixed { + return $default; +} + +function isset_request_var(string $name): bool { + return false; +} + +function isempty_request_var(string $name): bool { + return false; +} + +function set_default_action(string $default = ''): void { +} + +/** + * @param array $filters + */ +function validate_store_request_vars(array $filters, string $sess_prefix = ''): void { +} + +function sanitize_search_string(mixed $string): string { + return ''; +} + +function html_escape_request_var(string $name): string { +} + +// ----- Logging / Misc ------------------------------------------------ + +function cacti_log(string $string, bool $output = false, string $environment = '', int $level = 1, bool $force = false): bool { + return false; +} + +function cacti_sizeof(mixed $array): int { + return is_array($array) ? count($array) : 0; +} + +function get_client_addr(): string { + return ''; +} + +function get_username(int $user_id): string { + return ''; +} + +function raise_message(string $id, string $message = '', int $level = 0): void { +} + +function csrf_check(bool $force = false): bool { + return false; +} + +// ----- UI / HTML Helpers --------------------------------------------- + +function top_header(): void { +} + +function bottom_footer(): void { +} + +function html_start_box(string $title, string $width, string $align = '', string $add = '', string $collapsible = '', string $url = ''): void { +} + +function html_end_box(bool $trailing_br = true): void { +} + +/** + * @param array ...$args + */ +function html_nav_bar(string $base_url, int $max_pages, int $current_page, int $rows_per_page, int $total_rows, int $pages_to_display, string $title, string $page_var = 'page', string $class = 'main'): string { + return ''; +} + +/** + * @param array> $display_text + */ +function html_header_sort(array $display_text, string $sort_column, string $sort_direction, bool $last_column = false): void { +} + +function html_escape(mixed $string): string { + return ''; +} + +function form_alternate_row(string $id, bool $light = false): void { +} + +function form_selectable_cell(string $text, int $id, string $class = '', string $title = ''): void { +} + +function form_selectable_ecell(string $text, int $id, string $class = '', string $title = ''): void { +} + +function form_end_row(): void { +} + +function filter_value(string $text, string $filter): string { + return ''; +} + +function get_order_string(): string { + return ''; +} + +/** + * @param array>|false $array + */ +function array_rekey(array|false $array, string $key, string $key_value): array { + return []; +} diff --git a/setup.php b/setup.php index 7d74976..7722254 100644 --- a/setup.php +++ b/setup.php @@ -24,7 +24,7 @@ include_once('audit_functions.php'); -function plugin_audit_install() { +function plugin_audit_install(): void { api_plugin_register_hook('audit', 'config_arrays', 'audit_config_arrays', 'setup.php'); api_plugin_register_hook('audit', 'config_settings', 'audit_config_settings', 'setup.php'); api_plugin_register_hook('audit', 'config_insert', 'audit_config_insert', 'setup.php'); @@ -33,20 +33,52 @@ function plugin_audit_install() { api_plugin_register_hook('audit', 'utilities_array', 'audit_utilities_array', 'setup.php'); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php'); api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php'); + api_plugin_register_hook('audit', 'logout_post_session_destroy', 'audit_logout_post_session_destroy', 'audit_functions.php'); + api_plugin_register_hook('audit', 'custom_denied', 'audit_custom_denied', 'audit_functions.php'); - /* hook for table replication */ + // hook for table replication api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php'); audit_setup_realms(true); audit_setup_table(); + audit_persist_auth_defaults(); } -function audit_setup_realms($grant_installing_user = false) { - $realms = array( +/** + * Persist authentication auditing defaults without overwriting existing + * administrator choices. Called on fresh install and upgrade so that + * ordinary-user logout and authorization-denied hooks work with the + * advertised default even though the configuration controls remain hidden + * from non-Audit-Admin users. + */ +function audit_persist_auth_defaults(): void { + $defaults = [ + 'audit_auth_log_enabled' => 'on', + 'audit_brute_force_enabled' => 'on', + 'audit_brute_force_window_minutes' => '5', + 'audit_brute_force_threshold' => '10', + 'audit_brute_force_last_alert' => '', + 'audit_user_log_batch_size' => '1000' + ]; + + foreach ($defaults as $name => $value) { + $exists = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) FROM settings WHERE name = ?', + [$name] + ); + + if ($exists === 0) { + set_config_option($name, $value); + } + } +} + +function audit_setup_realms(bool $grant_installing_user = false): void { + $realms = [ '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); @@ -60,39 +92,43 @@ function audit_setup_realms($grant_installing_user = false) { FROM plugin_realms WHERE plugin = ? AND file IN (?, ?)', - array('audit', 'audit.php', 'audit_manage.php')); + ['audit', 'audit.php', 'audit_manage.php']); - foreach ($realm_ids as $realm) { - db_execute_prepared('REPLACE INTO user_auth_realm + if (is_array($realm_ids)) { + foreach ($realm_ids as $realm) { + db_execute_prepared('REPLACE INTO user_auth_realm (user_id, realm_id) VALUES (?, ?)', - array($admin_user, $realm['realm_id'])); + [$admin_user, $realm['realm_id']]); + } } } } } -function audit_remove_deprecated_realms() { +function audit_remove_deprecated_realms(): void { $realms = db_fetch_assoc_prepared('SELECT id FROM plugin_realms WHERE plugin = ? AND file = ?', - array('audit', 'audit_purge.php')); + ['audit', 'audit_purge.php']); - foreach ($realms as $realm) { - $realm_id = $realm['id'] + 100; + if (is_array($realms)) { + 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_realm + WHERE realm_id = ?', + [$realm_id]); - db_execute_prepared('DELETE FROM user_auth_group_realm - WHERE realm_id = ?', - array($realm_id)); + db_execute_prepared('DELETE FROM user_auth_group_realm + WHERE realm_id = ?', + [$realm_id]); - db_execute_prepared('DELETE FROM plugin_realms - WHERE id = ?', - array($realm['id'])); + db_execute_prepared('DELETE FROM plugin_realms + WHERE id = ?', + [$realm['id']]); + } } if (cacti_sizeof($realms)) { @@ -100,13 +136,19 @@ function audit_remove_deprecated_realms() { } } -function plugin_audit_uninstall() { +function plugin_audit_uninstall(): bool { + db_execute('DROP TABLE IF EXISTS audit_user_log_state'); db_execute('DROP TABLE IF EXISTS audit_syslog_delivery'); db_execute('DROP TABLE IF EXISTS audit_log'); + db_execute_prepared( + 'DELETE FROM settings WHERE LEFT(name, 6) = ?', + ['audit_'] + ); + return true; } -function audit_is_console_page($url) { +function audit_is_console_page(string $url): bool { if (strpos($url, 'audit.php') !== false) { return true; } @@ -114,34 +156,37 @@ function audit_is_console_page($url) { return false; } -function plugin_audit_check_config() { +function plugin_audit_check_config(): bool { return true; } -function plugin_audit_upgrade() { +function plugin_audit_upgrade(): bool { return true; } -function audit_check_upgrade() { +function audit_check_upgrade(): void { global $config, $database_default; include_once($config['library_path'] . '/database.php'); include_once($config['library_path'] . '/functions.php'); - $files = array('plugins.php', 'audit.php'); - if (isset($_SERVER['PHP_SELF']) && !in_array(basename($_SERVER['PHP_SELF']), $files)) { + $files = ['plugins.php', 'audit.php']; + + if (isset($_SERVER['PHP_SELF']) && !in_array(basename($_SERVER['PHP_SELF']), $files, true)) { return; } $info = plugin_audit_version(); $current = $info['version']; - $old = db_fetch_cell_prepared('SELECT version FROM plugin_config WHERE directory = ?', array('audit')); + $old = db_fetch_cell_prepared('SELECT version FROM plugin_config WHERE directory = ?', ['audit']); + if ($current != $old) { if (api_plugin_is_enabled('audit')) { - # may sound ridiculous, but enables new hooks + // may sound ridiculous, but enables new hooks api_plugin_enable_hooks('audit'); } db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS object_data LONGBLOB'); + if (db_column_exists('audit_log', 'outcome')) { if (!db_column_exists('audit_log', 'request_status')) { db_execute("ALTER TABLE audit_log CHANGE COLUMN outcome request_status varchar(20) NOT NULL DEFAULT 'unknown'"); @@ -162,13 +207,15 @@ function audit_check_upgrade() { db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status'); audit_upgrade_event_schema(); audit_setup_syslog_table(); + audit_setup_user_log_state_table(); + audit_persist_auth_defaults(); audit_setup_realms(); audit_remove_deprecated_realms(); db_execute_prepared('UPDATE plugin_config SET version = ? WHERE directory = ?', - array($current, 'audit')); + [$current, 'audit']); db_execute_prepared('UPDATE plugin_config SET version = ?, @@ -176,16 +223,22 @@ function audit_check_upgrade() { author = ?, webpage = ? WHERE directory = ?', - array($info['version'], $info['longname'], $info['author'], $info['homepage'], $info['name'])); + [$info['version'], $info['longname'], $info['author'], $info['homepage'], $info['name']]); - /* hook for table replication */ - api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php', '1'); + // hook for table replication + api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php', 1); api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php', 1); api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php', 1); + api_plugin_register_hook('audit', 'logout_post_session_destroy', 'audit_logout_post_session_destroy', 'audit_functions.php', 1); + api_plugin_register_hook('audit', 'custom_denied', 'audit_custom_denied', 'audit_functions.php', 1); } } -function audit_replicate_out($data) { +/** + * @param array $data + * @return array + */ +function audit_replicate_out(array $data): array { $rcnn_id = $data['rcnn_id']; $class = $data['class']; @@ -210,6 +263,7 @@ function audit_replicate_out($data) { } db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS object_data LONGBLOB', true, $rcnn_id); + if (db_column_exists('audit_log', 'outcome', false, $rcnn_id)) { if (!db_column_exists('audit_log', 'request_status', false, $rcnn_id)) { db_execute("ALTER TABLE audit_log CHANGE COLUMN outcome request_status varchar(20) NOT NULL DEFAULT 'unknown'", true, $rcnn_id); @@ -229,15 +283,31 @@ function audit_replicate_out($data) { db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data", true, $rcnn_id); db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status', true, $rcnn_id); audit_upgrade_event_schema($rcnn_id); + + // Replicate and migrate durable user_log deduplication state. + audit_setup_user_log_state_table($rcnn_id); } return $data; } -function audit_poller_bottom() { +function audit_poller_bottom(): void { audit_retry_external_logs(); audit_process_syslog_queue(); + // Brute-force detection runs every poller cycle so short bursts are + // caught in near-real-time. Only alert emission is throttled inside the + // function via audit_brute_force_last_alert. + audit_detect_brute_force(); + + // Authentication events are captured by polling Cacti's user_log table, + // which is authoritative across all auth methods (local, LDAP, basic, + // domains) and stable across the 1.2.x and develop branches. Ingestion + // runs every poller cycle with a bounded workload so login failures and + // authorization events appear promptly; the deduplication table prevents + // duplicate events across repeated and concurrent pollers. + audit_poll_user_log(); + $last_check = read_config_option('audit_last_check'); $now = gmdate('Y-m-d'); @@ -255,16 +325,26 @@ function audit_poller_bottom() { WHERE audit_syslog_delivery.audit_id = audit_log.id AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter') )", - array($cutoff->format('Y-m-d H:i:s'))); + [$cutoff->format('Y-m-d H:i:s')]); $rows = db_affected_rows(); cacti_log('NOTE: Purged ' . $rows . ' Audit Log Records from Cacti', false, 'POLLER'); + + // Deduplication state intentionally survives audit_log deletion so + // recent user_log rows are not imported again. Markers older than + // this cutoff can be removed safely because polling never selects + // source rows outside the same retention window. + if (db_table_exists('audit_user_log_state')) { + db_execute_prepared('DELETE FROM audit_user_log_state + WHERE source_time < ?', + [$cutoff->format('Y-m-d H:i:s')]); + } } } set_config_option('audit_last_check', $now); } -function audit_setup_table() { +function audit_setup_table(): bool { global $config, $database_default; include_once($config['library_path'] . '/database.php'); @@ -316,11 +396,65 @@ function audit_setup_table() { COMMENT='Audit Log for all GUI activities'"); audit_setup_syslog_table(); + audit_setup_user_log_state_table(); return true; } -function audit_setup_syslog_table() { +/** + * Durable, database-backed deduplication table for user_log ingestion. + * + * Each processed user_log primary-key tuple (username, user_id, time) is + * recorded as a deterministic SHA-256 hash so repeated and concurrent + * pollers cannot double-record the same source row. audit_id is deliberately + * not a foreign key: deduplication state must survive audit-log retention and + * manual purges, otherwise recent user_log rows would be imported again. + */ +function audit_setup_user_log_state_table(mixed $cnn_id = false): void { + db_execute("CREATE TABLE IF NOT EXISTS `audit_user_log_state` ( + `source_hash` char(64) NOT NULL, + `source_key` varchar(160) NOT NULL DEFAULT '', + `source_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `audit_id` bigint(20) unsigned NOT NULL, + `processed_time` datetime(6) NOT NULL, + PRIMARY KEY (`source_hash`), + KEY `source_time_key` (`source_time`, `source_key`)) + ENGINE=InnoDB + COMMENT='Durable deduplication state for user_log ingestion'", + true, + $cnn_id + ); + + $has_foreign_key = (int) db_fetch_cell_prepared( + 'SELECT COUNT(*) + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ? + AND CONSTRAINT_TYPE = ?', + ['audit_user_log_state', 'fk_audit_user_log_state_event', 'FOREIGN KEY'], + '', + true, + $cnn_id + ); + + if ($has_foreign_key > 0) { + db_execute( + 'ALTER TABLE audit_user_log_state + DROP FOREIGN KEY fk_audit_user_log_state_event', + true, + $cnn_id + ); + } + + db_execute('ALTER TABLE audit_user_log_state + ADD COLUMN IF NOT EXISTS source_key varchar(160) NOT NULL DEFAULT "" AFTER source_hash', + true, + $cnn_id + ); +} + +function audit_setup_syslog_table(): void { db_execute("CREATE TABLE IF NOT EXISTS `audit_syslog_delivery` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `audit_id` bigint(20) unsigned NOT NULL, @@ -350,51 +484,51 @@ function audit_setup_syslog_table() { db_execute("ALTER TABLE audit_syslog_delivery ADD COLUMN IF NOT EXISTS node_id varchar(255) NOT NULL DEFAULT 'cacti' AFTER destination_fingerprint"); - db_execute("ALTER TABLE audit_syslog_delivery + db_execute('ALTER TABLE audit_syslog_delivery ADD COLUMN IF NOT EXISTS poller_id varchar(64) DEFAULT NULL - AFTER node_id"); + AFTER node_id'); } -function audit_upgrade_event_schema($rcnn_id = false) { - $remote = $rcnn_id !== false; - $args = $remote ? array(true, $rcnn_id) : array(); - $columns = array( - "event_uuid char(36) DEFAULT NULL", - "correlation_id char(36) DEFAULT NULL", +function audit_upgrade_event_schema(mixed $rcnn_id = false): void { + $remote = $rcnn_id !== false; + $args = $remote ? [true, $rcnn_id] : []; + $columns = [ + 'event_uuid char(36) DEFAULT NULL', + 'correlation_id char(36) DEFAULT NULL', "event_type varchar(100) NOT NULL DEFAULT 'cacti.request'", "event_category varchar(40) NOT NULL DEFAULT 'configuration'", "severity varchar(12) NOT NULL DEFAULT 'info'", "actor_type varchar(20) NOT NULL DEFAULT 'user'", - "target_type varchar(64) DEFAULT NULL", - "target_id varchar(128) DEFAULT NULL", + 'target_type varchar(64) DEFAULT NULL', + 'target_id varchar(128) DEFAULT NULL', "operation_outcome varchar(20) NOT NULL DEFAULT 'unknown'", - "outcome_reason varchar(255) DEFAULT NULL", - "http_method varchar(10) DEFAULT NULL", - "http_status smallint unsigned DEFAULT NULL", - "completed_time datetime(6) DEFAULT NULL", - "duration_ms bigint unsigned DEFAULT NULL", - "details longblob", - "previous_hash char(64) DEFAULT NULL", - "integrity_hash char(64) DEFAULT NULL", - "external_attempts int unsigned NOT NULL DEFAULT 0", - "external_last_attempt datetime(6) DEFAULT NULL", - "external_delivered_time datetime(6) DEFAULT NULL" - ); + 'outcome_reason varchar(255) DEFAULT NULL', + 'http_method varchar(10) DEFAULT NULL', + 'http_status smallint unsigned DEFAULT NULL', + 'completed_time datetime(6) DEFAULT NULL', + 'duration_ms bigint unsigned DEFAULT NULL', + 'details longblob', + 'previous_hash char(64) DEFAULT NULL', + 'integrity_hash char(64) DEFAULT NULL', + 'external_attempts int unsigned NOT NULL DEFAULT 0', + 'external_last_attempt datetime(6) DEFAULT NULL', + 'external_delivered_time datetime(6) DEFAULT NULL' + ]; foreach ($columns as $definition) { call_user_func_array('db_execute', array_merge( - array('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS ' . $definition), + ['ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS ' . $definition], $args )); } - $indexes = array( - 'event_uuid' => array('UNIQUE INDEX', array('event_uuid')), - 'correlation_id' => array('INDEX', array('correlation_id')), - 'event_type' => array('INDEX', array('event_type')), - 'operation_outcome' => array('INDEX', array('operation_outcome')), - 'external_status' => array('INDEX', array('external_status')) - ); + $indexes = [ + 'event_uuid' => ['UNIQUE INDEX', ['event_uuid']], + 'correlation_id' => ['INDEX', ['correlation_id']], + 'event_type' => ['INDEX', ['event_type']], + 'operation_outcome' => ['INDEX', ['operation_outcome']], + 'external_status' => ['INDEX', ['external_status']] + ]; foreach ($indexes as $name => $definition) { if (!db_index_exists('audit_log', $name, false, $remote ? $rcnn_id : false)) { @@ -403,13 +537,18 @@ function audit_upgrade_event_schema($rcnn_id = false) { } } -function plugin_audit_version() { +/** + * @return array + */ +function plugin_audit_version(): array { global $config; - $info = parse_ini_file($config['base_path'] . '/plugins/audit/INFO', true); - return $info['info']; + $info = parse_ini_file($config['base_path'] . '/plugins/audit/INFO', true); + $plugin_info = is_array($info) ? ($info['info'] ?? null) : null; + + return is_array($plugin_info) ? $plugin_info : []; } -function audit_log_valid_event() { +function audit_log_valid_event(): bool { global $action; $valid = false; @@ -443,32 +582,32 @@ function audit_log_valid_event() { return $valid; } -function audit_utilities_array() { +function audit_utilities_array(): void { global $utilities; if (version_compare(CACTI_VERSION, '1.3.0', '<')) { if (api_plugin_user_realm_auth('audit.php')) { $utilities[__('Technical Support', 'audit')] = array_merge( $utilities[__('Technical Support', 'audit')], - array( - __('View Audit Log', 'audit') => array( - 'link' => 'plugins/audit/audit.php', + [ + __('View Audit Log', 'audit') => [ + 'link' => 'plugins/audit/audit.php', 'description' => __('Allows Administrators to view change activity on the Cacti server. Administrators can also export the audit log for analysis purposes.', 'audit') - ) - ) + ] + ] ); } } } -function audit_config_arrays() { +function audit_config_arrays(): void { global $menu, $messages, $audit_retentions, $utilities; if (isset($_SESSION['audit_message']) && $_SESSION['audit_message'] != '') { - $messages['audit_message'] = array('message' => $_SESSION['audit_message'], 'type' => 'info'); + $messages['audit_message'] = ['message' => $_SESSION['audit_message'], 'type' => 'info']; } - $audit_retentions = array( + $audit_retentions = [ -1 => __('Indefinitely', 'audit'), 14 => __('%d Weeks', 2, 'audit'), 30 => __('%d Month', 1, 'audit'), @@ -479,237 +618,281 @@ function audit_config_arrays() { 365 => __('%d Year', 1, 'audit'), 730 => __('%d Years', 2, 'audit'), 1095 => __('%d Years', 3, 'audit') - ); + ]; $menu[__('Utilities')]['plugins/audit/audit.php'] = __('Audit Log', 'audit'); if (function_exists('auth_augment_roles')) { - auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php')); + auth_augment_roles(__('Audit Plugin', 'audit'), ['audit.php', 'audit_manage.php']); } audit_check_upgrade(); } -function audit_config_settings() { +function audit_config_settings(): void { global $tabs, $settings, $item_rows, $audit_retentions; - $temp = array( - 'audit_header' => array( + $temp = [ + 'audit_header' => [ 'friendly_name' => __('Audit Log Settings', 'audit'), - 'method' => 'spacer', - ), - 'audit_enabled' => array( + 'method' => 'spacer', + ], + 'audit_enabled' => [ 'friendly_name' => __('Enable Audit Log', 'audit'), - 'description' => __('Check this box, if you want the Audit Log to track GUI activities.', 'audit'), - 'method' => 'checkbox', - 'default' => 'on' - ), - 'audit_retention' => array( + 'description' => __('Check this box, if you want the Audit Log to track GUI activities.', 'audit'), + 'method' => 'checkbox', + 'default' => 'on' + ], + 'audit_retention' => [ 'friendly_name' => __('Audit Log Retention', 'audit'), - 'description' => __('How long do you wish Audit Log entries to be retained?', 'audit'), - 'method' => 'drop_array', - 'default' => '90', - 'array' => $audit_retentions - ), - 'audit_log_external' => array( + 'description' => __('How long do you wish Audit Log entries to be retained?', 'audit'), + 'method' => 'drop_array', + 'default' => '90', + 'array' => $audit_retentions + ], + 'audit_log_external' => [ 'friendly_name' => __('External Audit Log', 'audit'), - 'description' => __('Check this box, if you want the Audit Log to be written to an external file.', 'audit'), - 'method' => 'checkbox', - 'default' => 'off' - ), - 'audit_log_external_format' => array( + 'description' => __('Check this box, if you want the Audit Log to be written to an external file.', 'audit'), + 'method' => 'checkbox', + 'default' => 'off' + ], + 'audit_log_external_format' => [ 'friendly_name' => __('External Audit Log Format', 'audit'), - 'description' => __('Select the output format for external audit log records.', 'audit'), - 'method' => 'drop_array', - 'default' => 'json', - 'array' => array( + 'description' => __('Select the output format for external audit log records.', 'audit'), + 'method' => 'drop_array', + 'default' => 'json', + 'array' => [ 'text' => __('Text', 'audit'), 'json' => __('JSON', 'audit') - ) - ), - 'audit_log_external_path' => array( + ] + ], + 'audit_log_external_path' => [ 'friendly_name' => __('External Audit Log Log file Path', 'audit'), - 'description' => __('Enter the path to the external audit log file.', 'audit'), - 'method' => 'filepath', - 'default' => '/var/www/html/cacti/log/audit.log', - 'max_length' => '255' - ), - ); + 'description' => __('Enter the path to the external audit log file.', 'audit'), + 'method' => 'filepath', + 'default' => '/var/www/html/cacti/log/audit.log', + 'max_length' => '255' + ], + ]; if (php_sapi_name() === 'cli' || audit_user_is_admin()) { - $facility_options = array(); + $auth_settings = [ + 'audit_auth_header' => [ + 'friendly_name' => __('Authentication Auditing', 'audit'), + 'method' => 'spacer', + ], + 'audit_auth_log_enabled' => [ + 'friendly_name' => __('Enable Authentication Auditing', 'audit'), + 'description' => __('Check this box to capture login, logout, token, password-change, and authorization-denied events by polling the Cacti user_log table and supported hooks.', 'audit'), + 'method' => 'checkbox', + 'default' => 'on' + ], + 'audit_brute_force_enabled' => [ + 'friendly_name' => __('Enable Brute-force Detection', 'audit'), + 'description' => __('Check this box to emit a critical audit event when failed logins exceed the threshold within the configured window.', 'audit'), + 'method' => 'checkbox', + 'default' => 'on' + ], + 'audit_brute_force_window_minutes' => [ + 'friendly_name' => __('Brute-force Window (minutes)', 'audit'), + 'description' => __('Rolling window in minutes, from 1 through 1440, used to count failed logins.', 'audit'), + 'method' => 'textbox', + 'default' => '5', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_brute_force_threshold' => [ + 'friendly_name' => __('Brute-force Threshold', 'audit'), + 'description' => __('Number of failed logins within the window, from 1 through 1000, that triggers a brute-force alert.', 'audit'), + 'method' => 'textbox', + 'default' => '10', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_user_log_batch_size' => [ + 'friendly_name' => __('User Log Ingestion Batch Size', 'audit'), + 'description' => __('Maximum user_log rows ingested per poller cycle, from 1 through 5000. Larger batches process backlogs faster but increase poller runtime.', 'audit'), + 'method' => 'textbox', + 'default' => '1000', + 'max_length' => '4', + 'size' => '8' + ], + ]; + + $facility_options = []; + foreach (audit_syslog_facilities() as $facility => $code) { $facility_options[$facility] = strtoupper($facility) . ' (' . $code . ')'; } - $syslog = array( - 'audit_syslog_header' => array( + $syslog = [ + 'audit_syslog_header' => [ 'friendly_name' => __('Remote Syslog', 'audit'), - 'method' => 'spacer' - ), - 'audit_syslog_enabled' => array( + 'method' => 'spacer' + ], + 'audit_syslog_enabled' => [ 'friendly_name' => __('Enable Remote Syslog', 'audit'), - 'description' => __('Queue finalized audit events for remote Syslog delivery. The existing external file output remains independent.', 'audit'), - 'method' => 'checkbox', - 'default' => 'off' - ), - 'audit_syslog_receiver' => array( + 'description' => __('Queue finalized audit events for remote Syslog delivery. The existing external file output remains independent.', 'audit'), + 'method' => 'checkbox', + 'default' => 'off' + ], + 'audit_syslog_receiver' => [ 'friendly_name' => __('Syslog Receiver', 'audit'), - 'description' => __('Enter a receiver hostname or IP address without a URI scheme, path, or credentials.', 'audit'), - 'method' => 'textbox', - 'default' => '', - 'max_length' => '253', - 'size' => '60' - ), - 'audit_syslog_port' => array( + 'description' => __('Enter a receiver hostname or IP address without a URI scheme, path, or credentials.', 'audit'), + 'method' => 'textbox', + 'default' => '', + 'max_length' => '253', + 'size' => '60' + ], + 'audit_syslog_port' => [ 'friendly_name' => __('Syslog Port', 'audit'), - 'description' => __('Enter 1-65535, or leave blank to use 514 for UDP/TCP and 6514 for TLS.', 'audit'), - 'method' => 'textbox', - 'default' => '', - 'max_length' => '5', - 'size' => '8' - ), - 'audit_syslog_transport' => array( + 'description' => __('Enter 1-65535, or leave blank to use 514 for UDP/TCP and 6514 for TLS.', 'audit'), + 'method' => 'textbox', + 'default' => '', + 'max_length' => '5', + 'size' => '8' + ], + 'audit_syslog_transport' => [ 'friendly_name' => __('Syslog Transport', 'audit'), - 'description' => __('UDP sends one datagram without acknowledgement. TCP and TLS use RFC 6587 octet-count framing.', 'audit'), - 'method' => 'drop_array', - 'default' => 'udp', - 'array' => array( + 'description' => __('UDP sends one datagram without acknowledgement. TCP and TLS use RFC 6587 octet-count framing.', 'audit'), + 'method' => 'drop_array', + 'default' => 'udp', + 'array' => [ 'udp' => __('UDP', 'audit'), 'tcp' => __('TCP', 'audit'), 'tls' => __('TLS', 'audit') - ) - ), - 'audit_syslog_format' => array( + ] + ], + 'audit_syslog_format' => [ 'friendly_name' => __('Syslog Payload Format', 'audit'), - 'description' => __('All formats use an RFC 5424 header. Select RFC 5424 structured data, CEF, or compact JSON for the message.', 'audit'), - 'method' => 'drop_array', - 'default' => 'json', - 'array' => array( + 'description' => __('All formats use an RFC 5424 header. Select RFC 5424 structured data, CEF, or compact JSON for the message.', 'audit'), + 'method' => 'drop_array', + 'default' => 'json', + 'array' => [ 'rfc5424' => __('RFC 5424', 'audit'), - 'cef' => __('CEF', 'audit'), - 'json' => __('JSON', 'audit') - ) - ), - 'audit_syslog_facility' => array( + 'cef' => __('CEF', 'audit'), + 'json' => __('JSON', 'audit') + ] + ], + 'audit_syslog_facility' => [ 'friendly_name' => __('Syslog Facility', 'audit'), - 'description' => __('Select the facility used to calculate the RFC 5424 priority.', 'audit'), - 'method' => 'drop_array', - 'default' => 'local0', - 'array' => $facility_options - ), - 'audit_syslog_application' => array( + 'description' => __('Select the facility used to calculate the RFC 5424 priority.', 'audit'), + 'method' => 'drop_array', + 'default' => 'local0', + 'array' => $facility_options + ], + 'audit_syslog_application' => [ 'friendly_name' => __('Syslog Application Name', 'audit'), - 'description' => __('RFC 5424 APP-NAME. Printable non-space ASCII, up to 48 characters.', 'audit'), - 'method' => 'textbox', - 'default' => 'cacti-audit', - 'max_length' => '48', - 'size' => '30' - ), - 'audit_syslog_node_id' => array( + 'description' => __('RFC 5424 APP-NAME. Printable non-space ASCII, up to 48 characters.', 'audit'), + 'method' => 'textbox', + 'default' => 'cacti-audit', + 'max_length' => '48', + 'size' => '30' + ], + 'audit_syslog_node_id' => [ 'friendly_name' => __('Audit Node Identity', 'audit'), - 'description' => __('Stable RFC 5424 hostname identity for this Cacti node. Do not use a value that changes on restart.', 'audit'), - 'method' => 'textbox', - 'default' => php_uname('n'), - 'max_length' => '255', - 'size' => '60' - ), - 'audit_syslog_timeout' => array( + 'description' => __('Stable RFC 5424 hostname identity for this Cacti node. Do not use a value that changes on restart.', 'audit'), + 'method' => 'textbox', + 'default' => php_uname('n'), + 'max_length' => '255', + 'size' => '60' + ], + 'audit_syslog_timeout' => [ 'friendly_name' => __('Connection and Write Timeout', 'audit'), - 'description' => __('Timeout in seconds, from 1 through 30.', 'audit'), - 'method' => 'textbox', - 'default' => '5', - 'max_length' => '2', - 'size' => '8' - ), - 'audit_syslog_udp_max_size' => array( + 'description' => __('Timeout in seconds, from 1 through 30.', 'audit'), + 'method' => 'textbox', + 'default' => '5', + 'max_length' => '2', + 'size' => '8' + ], + 'audit_syslog_udp_max_size' => [ 'friendly_name' => __('Maximum UDP Record Size', 'audit'), - 'description' => __('Records larger than this byte limit are dead-lettered and are never truncated or split. TCP or TLS is recommended for large events.', 'audit'), - 'method' => 'textbox', - 'default' => '8192', - 'max_length' => '5', - 'size' => '10' - ), - 'audit_syslog_tls_header' => array( + 'description' => __('Records larger than this byte limit are dead-lettered and are never truncated or split. TCP or TLS is recommended for large events.', 'audit'), + 'method' => 'textbox', + 'default' => '8192', + 'max_length' => '5', + 'size' => '10' + ], + 'audit_syslog_tls_header' => [ 'friendly_name' => __('Syslog TLS', 'audit'), - 'method' => 'spacer' - ), - 'audit_syslog_tls_ca_file' => array( + 'method' => 'spacer' + ], + 'audit_syslog_tls_ca_file' => [ 'friendly_name' => __('TLS CA File', 'audit'), - 'description' => __('Optional PEM CA bundle. Peer and hostname verification are always enabled.', 'audit'), - 'method' => 'filepath', - 'default' => '', - 'max_length' => '255' - ), - 'audit_syslog_tls_client_cert' => array( + 'description' => __('Optional PEM CA bundle. Peer and hostname verification are always enabled.', 'audit'), + 'method' => 'filepath', + 'default' => '', + 'max_length' => '255' + ], + 'audit_syslog_tls_client_cert' => [ 'friendly_name' => __('TLS Client Certificate', 'audit'), - 'description' => __('Optional PEM client certificate. A client key must also be configured.', 'audit'), - 'method' => 'filepath', - 'default' => '', - 'max_length' => '255' - ), - 'audit_syslog_tls_client_key' => array( + 'description' => __('Optional PEM client certificate. A client key must also be configured.', 'audit'), + 'method' => 'filepath', + 'default' => '', + 'max_length' => '255' + ], + 'audit_syslog_tls_client_key' => [ 'friendly_name' => __('TLS Client Private Key', 'audit'), - 'description' => __('Optional readable PEM private-key path. The key contents are never stored in audit events.', 'audit'), - 'method' => 'filepath', - 'default' => '', - 'max_length' => '255' - ), - 'audit_syslog_delivery_header' => array( + 'description' => __('Optional readable PEM private-key path. The key contents are never stored in audit events.', 'audit'), + 'method' => 'filepath', + 'default' => '', + 'max_length' => '255' + ], + 'audit_syslog_delivery_header' => [ 'friendly_name' => __('Syslog Delivery Queue', 'audit'), - 'method' => 'spacer' - ), - 'audit_syslog_retry_base' => array( + 'method' => 'spacer' + ], + 'audit_syslog_retry_base' => [ 'friendly_name' => __('Retry Base Delay', 'audit'), - 'description' => __('Initial retry delay in seconds, from 1 through 3600.', 'audit'), - 'method' => 'textbox', - 'default' => '30', - 'max_length' => '4', - 'size' => '8' - ), - 'audit_syslog_retry_max' => array( + 'description' => __('Initial retry delay in seconds, from 1 through 3600.', 'audit'), + 'method' => 'textbox', + 'default' => '30', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_syslog_retry_max' => [ 'friendly_name' => __('Maximum Retry Delay', 'audit'), - 'description' => __('Maximum retry delay in seconds, from the base delay through 86400.', 'audit'), - 'method' => 'textbox', - 'default' => '3600', - 'max_length' => '5', - 'size' => '8' - ), - 'audit_syslog_max_attempts' => array( + 'description' => __('Maximum retry delay in seconds, from the base delay through 86400.', 'audit'), + 'method' => 'textbox', + 'default' => '3600', + 'max_length' => '5', + 'size' => '8' + ], + 'audit_syslog_max_attempts' => [ 'friendly_name' => __('Maximum Delivery Attempts', 'audit'), - 'description' => __('Move a record to dead-letter after this many attempts, from 1 through 100.', 'audit'), - 'method' => 'textbox', - 'default' => '10', - 'max_length' => '3', - 'size' => '8' - ), - 'audit_syslog_batch_size' => array( + 'description' => __('Move a record to dead-letter after this many attempts, from 1 through 100.', 'audit'), + 'method' => 'textbox', + 'default' => '10', + 'max_length' => '3', + 'size' => '8' + ], + 'audit_syslog_batch_size' => [ 'friendly_name' => __('Poller Batch Size', 'audit'), - 'description' => __('Maximum due records processed per poller cycle, from 1 through 1000.', 'audit'), - 'method' => 'textbox', - 'default' => '100', - 'max_length' => '4', - 'size' => '8' - ), - 'audit_syslog_pending_age_warning' => array( + 'description' => __('Maximum due records processed per poller cycle, from 1 through 1000.', 'audit'), + 'method' => 'textbox', + 'default' => '100', + 'max_length' => '4', + 'size' => '8' + ], + 'audit_syslog_pending_age_warning' => [ 'friendly_name' => __('Pending Age Warning', 'audit'), - 'description' => __('Show an unhealthy warning when the oldest queued record reaches this age in seconds.', 'audit'), - 'method' => 'textbox', - 'default' => '900', - 'max_length' => '6', - 'size' => '10' - ), - 'audit_syslog_dead_letter_warning' => array( + 'description' => __('Show an unhealthy warning when the oldest queued record reaches this age in seconds.', 'audit'), + 'method' => 'textbox', + 'default' => '900', + 'max_length' => '6', + 'size' => '10' + ], + 'audit_syslog_dead_letter_warning' => [ 'friendly_name' => __('Dead-letter Warning Count', 'audit'), - 'description' => __('Show an unhealthy warning when this many records are dead-lettered.', 'audit'), - 'method' => 'textbox', - 'default' => '1', - 'max_length' => '7', - 'size' => '10' - ) - ); - - $temp = array_merge($temp, $syslog); + 'description' => __('Show an unhealthy warning when this many records are dead-lettered.', 'audit'), + 'method' => 'textbox', + 'default' => '1', + 'max_length' => '7', + 'size' => '10' + ] + ]; + + $temp = array_merge($temp, $auth_settings, $syslog); } $tabs['audit'] = __('Audit', 'audit'); @@ -721,13 +904,17 @@ function audit_config_settings() { } } -function audit_draw_navigation_text($nav) { - $nav['audit.php:'] = array( +/** + * @param array $nav + * @return array + */ +function audit_draw_navigation_text(array $nav): array { + $nav['audit.php:'] = [ 'title' => __('Audit Event Log', 'audit'), 'mapping' => 'index.php:', 'url' => 'audit.php', 'level' => '1' - ); + ]; return $nav; } diff --git a/tests/Pest.php b/tests/Pest.php index e2132a3..675e214 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -7,8 +7,6 @@ +-------------------------------------------------------------------------+ */ -/* - * Pest configuration file. - */ +// Pest configuration file. require_once __DIR__ . '/bootstrap.php'; diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php deleted file mode 100644 index bd8d5e1..0000000 --- a/tests/Security/Php74CompatibilityTest.php +++ /dev/null @@ -1,82 +0,0 @@ -not->toBeFalse("Required plugin file is missing: {$relativeFile}"); - expect(is_readable($path))->toBeTrue("Required plugin file is unreadable: {$relativeFile}"); - } - }); - - it('does not use str_contains (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - $contents = file_get_contents($path); - expect($contents)->not->toBeFalse("Unable to read {$relativeFile}"); - - expect(preg_match('/\bstr_contains\s*\(/', $contents))->toBe(0, - "{$relativeFile} uses str_contains() which requires PHP 8.0" - ); - } - }); - - it('does not use str_starts_with (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - $contents = file_get_contents($path); - expect($contents)->not->toBeFalse("Unable to read {$relativeFile}"); - - expect(preg_match('/\bstr_starts_with\s*\(/', $contents))->toBe(0, - "{$relativeFile} uses str_starts_with() which requires PHP 8.0" - ); - } - }); - - it('does not use str_ends_with (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - $contents = file_get_contents($path); - expect($contents)->not->toBeFalse("Unable to read {$relativeFile}"); - - expect(preg_match('/\bstr_ends_with\s*\(/', $contents))->toBe(0, - "{$relativeFile} uses str_ends_with() which requires PHP 8.0" - ); - } - }); - - it('does not use nullsafe operator (PHP 8.0)', function () use ($files) { - foreach ($files as $relativeFile) { - $path = realpath(__DIR__ . '/../../' . $relativeFile); - - $contents = file_get_contents($path); - expect($contents)->not->toBeFalse("Unable to read {$relativeFile}"); - - expect(preg_match('/\?->/', $contents))->toBe(0, - "{$relativeFile} uses nullsafe operator which requires PHP 8.0" - ); - } - }); -}); diff --git a/tests/Security/Php81SyntaxTest.php b/tests/Security/Php81SyntaxTest.php new file mode 100644 index 0000000..f0daa1f --- /dev/null +++ b/tests/Security/Php81SyntaxTest.php @@ -0,0 +1,43 @@ +not->toBeFalse("Required plugin file is missing: {$relativeFile}"); + expect(is_readable($path))->toBeTrue("Required plugin file is unreadable: {$relativeFile}"); + } + }); + + it('uses short array syntax', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + $contents = file_get_contents($path); + + expect(preg_match('/\barray\s*\(/', $contents))->toBe(0, + "{$relativeFile} still uses long array() syntax" + ); + } + }); +}); diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index fbdc78d..9a0c8cc 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -14,13 +14,12 @@ describe('prepared statement consistency in audit', function () { it('documents database helper usage in all plugin files', function () { - $targetFiles = array( + $targetFiles = [ 'audit.php', 'audit_functions.php', 'audit_syslog.php', 'setup.php', - ); - + ]; foreach ($targetFiles as $relativeFile) { $path = realpath(__DIR__ . '/../../' . $relativeFile); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php index 7f4a129..e3799d5 100644 --- a/tests/Security/SetupStructureTest.php +++ b/tests/Security/SetupStructureTest.php @@ -7,9 +7,7 @@ +-------------------------------------------------------------------------+ */ -/* - * Verify setup.php defines required plugin hooks and info function. - */ +// Verify setup.php defines required plugin hooks and info function. describe('audit setup.php structure', function () { $source = file_get_contents(realpath(__DIR__ . '/../../setup.php')); @@ -26,11 +24,24 @@ expect($source)->toContain('function plugin_audit_uninstall'); }); - it('returns version array with name key', function () use ($source) { - expect($source)->toMatch('/[\'\""]name[\'\""]\s*=>/'); + it('reads plugin info from INFO file', function () use ($source) { + expect($source)->toContain('parse_ini_file'); + expect($source)->toContain("'info'"); + }); + + it('returns an array when plugin info is missing or malformed', function () use ($source) { + expect($source)->toContain("\$info['info'] ?? null"); + expect($source)->toContain('is_array($plugin_info) ? $plugin_info : []'); }); - it('returns version array with version key', function () use ($source) { - expect($source)->toMatch('/[\'\""]version[\'\""]\s*=>/'); + it('INFO file defines name and version keys', function () { + $info_file = realpath(__DIR__ . '/../../INFO'); + expect($info_file)->not->toBeFalse('INFO file must exist'); + + $info = parse_ini_file($info_file, true); + expect($info)->not->toBeFalse('INFO file must be valid INI'); + expect($info)->toHaveKey('info'); + expect($info['info'])->toHaveKey('name'); + expect($info['info'])->toHaveKey('version'); }); }); diff --git a/tests/auth_audit_test.php b/tests/auth_audit_test.php new file mode 100644 index 0000000..b069d5b --- /dev/null +++ b/tests/auth_audit_test.php @@ -0,0 +1,693 @@ + 'on', + 'audit_auth_log_enabled' => 'on', + 'audit_brute_force_enabled' => 'on', + 'audit_brute_force_window_minutes' => '5', + 'audit_brute_force_threshold' => '10', + 'audit_brute_force_last_alert' => '', + 'audit_user_log_batch_size' => '1000', + 'audit_retention' => '90' +]; + +$audit_auth_recorded_events = []; +$audit_auth_user_log_rows = []; +$audit_auth_state_rows = []; +$audit_auth_failed_count = 0; +$audit_auth_set_options = []; +$audit_auth_settings_rows = []; +$audit_auth_insert_fails = false; +$audit_auth_fail_usernames = []; +$audit_auth_state_conflict = false; +$audit_auth_affected_rows = 0; +$audit_auth_transaction = null; +$audit_auth_log_exists = true; +$audit_auth_executed_sql = []; + +function read_config_option(string $name, bool $force = false): string { + global $audit_auth_config, $audit_auth_settings_rows; + + if (isset($audit_auth_settings_rows[$name])) { + return (string) $audit_auth_settings_rows[$name]; + } + + return $audit_auth_config[$name] ?? ''; +} + +function set_config_option(string $name, string $value): void { + global $audit_auth_set_options, $audit_auth_config, $audit_auth_settings_rows; + $audit_auth_config[$name] = $value; + $audit_auth_settings_rows[$name] = $value; + $audit_auth_set_options[$name][] = $value; +} + +function db_execute_prepared(string $sql, array $params = []): bool { + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction, $audit_auth_executed_sql; + + $audit_auth_executed_sql[] = $sql; + + if (strpos($sql, 'START TRANSACTION') !== false) { + $audit_auth_transaction = [ + 'events' => $audit_auth_recorded_events, + 'state' => $audit_auth_state_rows + ]; + + return true; + } + + if (strpos($sql, 'ROLLBACK') !== false) { + if (is_array($audit_auth_transaction)) { + $audit_auth_recorded_events = $audit_auth_transaction['events']; + $audit_auth_state_rows = $audit_auth_transaction['state']; + } + + $audit_auth_transaction = null; + + return true; + } + + if (strpos($sql, 'COMMIT') !== false) { + $audit_auth_transaction = null; + + return true; + } + + if (strpos($sql, 'INSERT INTO audit_log') !== false) { + $details = json_decode((string) ($params[24] ?? ''), true); + $username = is_array($details) ? (string) ($details['username'] ?? '') : ''; + + if ($audit_auth_insert_fails || in_array($username, $audit_auth_fail_usernames, true)) { + $audit_auth_affected_rows = 0; + + return false; + } + $audit_auth_recorded_events[] = ['sql' => $sql, 'params' => $params]; + $audit_auth_affected_rows = 1; + + return true; + } + + if (strpos($sql, 'INSERT IGNORE INTO audit_user_log_state') !== false) { + $hash = $params[0]; + + if ($audit_auth_state_conflict || isset($audit_auth_state_rows[$hash])) { + $audit_auth_affected_rows = 0; + } else { + $audit_auth_state_rows[$hash] = [ + 'source_hash' => $hash, + 'source_key' => $params[1], + 'source_time' => $params[2], + 'audit_id' => $params[3], + 'processed_time' => $params[4] + ]; + $audit_auth_affected_rows = 1; + } + + return true; + } + + if (strpos($sql, 'INSERT IGNORE INTO settings') !== false) { + $name = (string) $params[0]; + + if (!array_key_exists($name, $audit_auth_settings_rows)) { + $audit_auth_settings_rows[$name] = (string) $params[1]; + $audit_auth_affected_rows = 1; + } else { + $audit_auth_affected_rows = 0; + } + + return true; + } + + if (strpos($sql, 'UPDATE settings') !== false && strpos($sql, 'audit_brute_force_last_alert') !== false) { + $name = 'audit_brute_force_last_alert'; + $current = $audit_auth_settings_rows[$name] ?? ''; + $now = $params[0]; + $window = $params[2]; + $should_update = ($current === '' || $current === '0'); + + if (!$should_update && $current !== '') { + $ts = strtotime($current); + $now_ts = strtotime($now); + $should_update = ($ts !== false && $now_ts !== false && ($now_ts - $ts) >= ($window * 60)); + } + + if ($should_update) { + $audit_auth_settings_rows[$name] = $now; + $audit_auth_affected_rows = 1; + + return true; + } + + $audit_auth_affected_rows = 0; + + return true; + } + + if (strpos($sql, 'DELETE FROM audit_user_log_state') !== false) { + $audit_auth_state_rows = []; + + return true; + } + + return true; +} + +function db_fetch_insert_id(): int { + global $audit_auth_recorded_events, $audit_auth_insert_fails; + + if ($audit_auth_insert_fails) { + return 0; + } + + return count($audit_auth_recorded_events); +} + +function db_fetch_row_prepared(string $sql, array $params = []): array { + global $audit_auth_state_rows; + + if (strpos($sql, 'MAX(source_time)') !== false) { + if (empty($audit_auth_state_rows)) { + return []; + } + + $max_time = ''; + $max_hash = ''; + + foreach ($audit_auth_state_rows as $row) { + if ($row['source_time'] > $max_time || ($row['source_time'] === $max_time && ($row['source_key'] ?? '') > $max_hash)) { + $max_time = $row['source_time']; + $max_hash = $row['source_key'] ?? ''; + } + } + + return ['max_time' => $max_time, 'max_key' => $max_hash]; + } + + return []; +} + +/** + * SQL-interpreting stub: filters user_log rows by retention and durable + * deduplication state, orders by (time, username, user_id), and applies LIMIT. + */ +function db_fetch_assoc_prepared(string $sql, array $params = []): array { + global $audit_auth_user_log_rows, $audit_auth_state_rows; + + if (strpos($sql, 'FROM user_log') === false) { + return []; + } + + $cutoff = (string) ($params[0] ?? ''); + $filtered = []; + + foreach ($audit_auth_user_log_rows as $row) { + $time = (string) $row['time']; + $hash = audit_user_log_source_hash( + (string) $row['username'], + (int) $row['user_id'], + $time + ); + + if ($time > $cutoff && !isset($audit_auth_state_rows[$hash])) { + $filtered[] = $row; + } + } + + usort($filtered, function ($a, $b) { + if ($a['time'] !== $b['time']) { + return $a['time'] <=> $b['time']; + } + + if ($a['username'] !== $b['username']) { + return $a['username'] <=> $b['username']; + } + + return $a['user_id'] <=> $b['user_id']; + }); + + $limit = preg_match('/LIMIT\\s+(\\d+)/i', $sql, $matches) + ? (int) $matches[1] + : 1000; + + return array_slice($filtered, 0, $limit); +} + +function db_fetch_cell_prepared(string $sql, array $params = []): int|string { + global $audit_auth_failed_count, $audit_auth_state_rows; + + if (strpos($sql, 'COUNT(*)') !== false && strpos($sql, 'result = 0') !== false) { + return $audit_auth_failed_count; + } + + if (strpos($sql, 'SELECT 1 FROM audit_user_log_state') !== false) { + $hash = $params[0] ?? ''; + + return isset($audit_auth_state_rows[$hash]) ? 1 : 0; + } + + return ''; +} + +function db_affected_rows(): int { + global $audit_auth_affected_rows; + + return $audit_auth_affected_rows; +} + +function db_table_exists(string $table): bool { + global $audit_auth_log_exists; + + if ($table === 'audit_log') { + return $audit_auth_log_exists; + } + + return in_array($table, ['user_log', 'audit_user_log_state'], true); +} + +function cacti_sizeof(array|bool $array): int { + return is_array($array) ? count($array) : 0; +} + +function get_nfilter_request_var(string $name, mixed $default = null): mixed { + return $_REQUEST[$name] ?? $default; +} + +function get_request_var(string $name): mixed { + return $_REQUEST[$name] ?? ''; +} + +function api_plugin_user_realm_auth(string $filename = ''): bool { + return false; +} + +function html_escape(mixed $string): string { + return htmlspecialchars((string) $string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); +} + +function __(string $text, string $domain = ''): string { + return $text; +} + +function cacti_log(string $message, bool $also_print = false, string $log_type = '', int $level = 0): void { +} + +function audit_test_assert_same(mixed $expected, mixed $actual, string $message): void { + if ($expected !== $actual) { + fwrite(STDERR, $message . PHP_EOL); + fwrite(STDERR, 'Expected: ' . var_export($expected, true) . PHP_EOL); + fwrite(STDERR, 'Actual: ' . var_export($actual, true) . PHP_EOL); + exit(1); + } +} + +function audit_test_assert_true(bool $condition, string $message): void { + if (!$condition) { + fwrite(STDERR, $message . PHP_EOL); + exit(1); + } +} + +function audit_test_reset_state(): void { + global $audit_auth_recorded_events, $audit_auth_state_rows, $audit_auth_set_options, $audit_auth_settings_rows, $audit_auth_insert_fails, $audit_auth_fail_usernames, $audit_auth_state_conflict, $audit_auth_affected_rows, $audit_auth_transaction, $audit_auth_log_exists, $audit_auth_executed_sql; + $audit_auth_recorded_events = []; + $audit_auth_state_rows = []; + $audit_auth_set_options = []; + $audit_auth_settings_rows = []; + $audit_auth_insert_fails = false; + $audit_auth_fail_usernames = []; + $audit_auth_state_conflict = false; + $audit_auth_affected_rows = 0; + $audit_auth_transaction = null; + $audit_auth_log_exists = true; + $audit_auth_executed_sql = []; +} + +// --------------------------------------------------------------------------- +// 1. Result-code mapping (audit_user_log_event_descriptor) +// --------------------------------------------------------------------------- + +audit_test_assert_same('cacti.auth.login.failed', audit_user_log_event_descriptor(0, 0)['event_type'], 'Failed logins must map to a login failed event.'); +audit_test_assert_same('failure', audit_user_log_event_descriptor(0, 0)['outcome'], 'Failed logins must be a failure outcome.'); + +// result=1: credentials accepted, NOT success (Cacti writes before checks). +audit_test_assert_same('cacti.auth.login.credentials_accepted', audit_user_log_event_descriptor(1, 5)['event_type'], 'result=1 must be credentials_accepted, not login.success.'); +audit_test_assert_same('unknown', audit_user_log_event_descriptor(1, 5)['outcome'], 'result=1 must carry an unknown outcome, not success.'); + +audit_test_assert_same('cacti.auth.login.token', audit_user_log_event_descriptor(2, 5)['event_type'], 'Token success must map to a login token event.'); +audit_test_assert_same('success', audit_user_log_event_descriptor(2, 5)['outcome'], 'Token success must be a success outcome.'); + +// result=3/user_id>0: defensive, unknown outcome (no false success). +audit_test_assert_same('cacti.auth.password.changed', audit_user_log_event_descriptor(3, 5)['event_type'], 'result=3/user_id>0 must map to password.changed.'); +audit_test_assert_same('unknown', audit_user_log_event_descriptor(3, 5)['outcome'], 'result=3/user_id>0 must carry unknown, not a confirmed success.'); + +// result=3/user_id=0: ambiguous. +$ambiguous = audit_user_log_event_descriptor(3, 0); +audit_test_assert_same('cacti.auth.password_change_or_2fa_failed', $ambiguous['event_type'], 'result=3/user_id=0 must map to the ambiguous event type.'); +audit_test_assert_same('unknown', $ambiguous['outcome'], 'The ambiguous event must carry an unknown outcome.'); +audit_test_assert_true(isset($ambiguous['details']['ambiguous']), 'The ambiguous event must flag itself as ambiguous.'); + +// Unsupported result code: explicit unknown, not a fallthrough. +$unknown = audit_user_log_event_descriptor(99, 5); +audit_test_assert_same('cacti.auth.login.unknown', $unknown['event_type'], 'Unsupported result codes must map to an explicit unknown event.'); +audit_test_assert_same('unknown', $unknown['outcome'], 'Unsupported result codes must carry an unknown outcome.'); +audit_test_assert_same(99, $unknown['details']['unsupported_result_code'], 'The unsupported code must be recorded in details.'); + +// --------------------------------------------------------------------------- +// 2. audit_poll_user_log() records one event per new user_log row +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'alice', 'user_id' => 5, 'result' => 1, 'ip' => '10.0.0.1', 'time' => '2026-07-25 10:00:01'], + ['username' => 'bob', 'user_id' => 0, 'result' => 0, 'ip' => '10.0.0.2', 'time' => '2026-07-25 10:00:02'], + ['username' => 'carol', 'user_id' => 7, 'result' => 2, 'ip' => '10.0.0.3', 'time' => '2026-07-25 10:00:03'], + ['username' => 'dave', 'user_id' => 0, 'result' => 3, 'ip' => '10.0.0.4', 'time' => '2026-07-25 10:00:04'] +]; + +audit_poll_user_log(); + +audit_test_assert_same(4, count($audit_auth_recorded_events), 'audit_poll_user_log() must record one event per new user_log row.'); +audit_test_assert_same('cacti.auth.login.credentials_accepted', $audit_auth_recorded_events[0]['params'][12], 'First event must be credentials_accepted for result=1.'); +audit_test_assert_same('warning', $audit_auth_recorded_events[1]['params'][14], 'Failed login must carry warning severity.'); +audit_test_assert_same('failure', $audit_auth_recorded_events[1]['params'][18], 'Failed login must carry failure outcome.'); +audit_test_assert_same('cacti.auth.password_change_or_2fa_failed', $audit_auth_recorded_events[3]['params'][12], 'result=3/user_id=0 must map to the ambiguous event.'); +audit_test_assert_same('unknown', $audit_auth_recorded_events[3]['params'][18], 'The ambiguous row must carry unknown outcome.'); +audit_test_assert_same(4, count($audit_auth_state_rows), 'Four deduplication state rows must be written.'); + +// Re-polling must not double-record (deduplication via state table). +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Re-polling after durable state is recorded must not produce duplicates.'); + +// Disabling auth auditing must skip polling. +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +$audit_auth_user_log_rows = [ + ['username' => 'eve', 'user_id' => 9, 'result' => 1, 'ip' => '10.0.0.5', 'time' => '2026-07-25 11:00:00'] +]; +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'audit_poll_user_log() must be gated by audit_auth_log_enabled.'); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 3. More than 1,000 rows sharing one timestamp: bounded anti-join paging +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_config['audit_user_log_batch_size'] = '1000'; +$audit_auth_user_log_rows = []; + +for ($i = 0; $i < 1500; $i++) { + $audit_auth_user_log_rows[] = [ + 'username' => sprintf('user%04d', $i), + 'user_id' => $i + 1, + 'result' => 0, + 'ip' => '10.0.0.10', + 'time' => '2026-07-25 12:00:00' + ]; +} + +audit_poll_user_log(); +audit_test_assert_same(1000, count($audit_auth_recorded_events), 'First cycle must process exactly the batch size (1000) rows, not all 1500.'); +audit_test_assert_same(1000, count($audit_auth_state_rows), '1000 state rows must be written after the first cycle.'); + +// Second cycle excludes durable markers and processes the remaining 500. +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(500, count($audit_auth_recorded_events), 'Second cycle must process the remaining 500 rows via durable-state exclusion.'); +audit_test_assert_same(1500, count($audit_auth_state_rows), 'All 1500 state rows must be written after two cycles.'); + +// Third cycle: nothing left. +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Third cycle must find no new rows.'); + +// --------------------------------------------------------------------------- +// 4. Multiple timestamp pages +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'a', 'user_id' => 1, 'result' => 1, 'ip' => '10.0.0.1', 'time' => '2026-07-25 13:00:00'], + ['username' => 'b', 'user_id' => 2, 'result' => 0, 'ip' => '10.0.0.2', 'time' => '2026-07-25 13:00:01'], + ['username' => 'c', 'user_id' => 3, 'result' => 2, 'ip' => '10.0.0.3', 'time' => '2026-07-25 13:00:02'] +]; + +audit_poll_user_log(); +audit_test_assert_same(3, count($audit_auth_recorded_events), 'All three timestamp pages must be processed in one cycle.'); +$first_details = json_decode($audit_auth_recorded_events[0]['params'][24], true); +audit_test_assert_same('a', $first_details['username'], 'Rows must be ordered by time then username.'); + +// --------------------------------------------------------------------------- +// 5. Concurrent/overlapping poller ownership: no duplicates +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'concurrent', 'user_id' => 42, 'result' => 1, 'ip' => '10.0.0.99', 'time' => '2026-07-25 14:00:00'] +]; + +// Simulate a concurrent poller that already recorded the state row. +$hash = audit_user_log_source_hash('concurrent', 42, '2026-07-25 14:00:00'); +$audit_auth_state_rows[$hash] = [ + 'source_hash' => $hash, + 'source_key' => 'concurrent|42|2026-07-25 14:00:00', + 'source_time' => '2026-07-25 14:00:00', + 'audit_id' => 999, + 'processed_time' => '2026-07-25 14:00:01' +]; + +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A row already claimed by a concurrent poller must not be double-recorded.'); + +// Simulate both pollers selecting the row before either state marker is +// visible. The losing transaction must roll back its audit event when its +// INSERT IGNORE reports that another poller won the unique source hash. +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'racing', 'user_id' => 43, 'result' => 1, 'ip' => '10.0.0.100', 'time' => '2026-07-25 14:01:00'] +]; +$audit_auth_state_conflict = true; + +audit_poll_user_log(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A concurrent state-insert loser must roll back its duplicate audit event.'); +audit_test_assert_same(0, count($audit_auth_state_rows), 'A concurrent state-insert loser must not create a state marker.'); + +// --------------------------------------------------------------------------- +// 6. Failed audit inserts remain discoverable behind later successful rows +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_user_log_rows = [ + ['username' => 'failinsert', 'user_id' => 50, 'result' => 1, 'ip' => '10.0.0.50', 'time' => '2026-07-25 15:00:00'], + ['username' => 'later', 'user_id' => 51, 'result' => 1, 'ip' => '10.0.0.51', 'time' => '2026-07-25 15:00:01'] +]; +$audit_auth_fail_usernames = ['failinsert']; + +audit_poll_user_log(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'A later row must still be recorded when an earlier audit insert fails.'); +audit_test_assert_same(1, count($audit_auth_state_rows), 'Only the successful later row may receive a state marker.'); + +// Retry after the insert recovers: the earlier row must remain discoverable +// even though a later source time has already been processed. +$audit_auth_fail_usernames = []; +$audit_auth_recorded_events = []; +audit_poll_user_log(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'A failed row behind a later success must be retried on the next cycle.'); +audit_test_assert_same(2, count($audit_auth_state_rows), 'The retried row must produce its durable state marker.'); + +// --------------------------------------------------------------------------- +// 7. Retention policy excludes arbitrary historical rows +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_config['audit_retention'] = '30'; +$audit_auth_user_log_rows = [ + ['username' => 'old', 'user_id' => 1, 'result' => 1, 'ip' => '10.0.0.1', 'time' => '2026-06-01 00:00:00'], + ['username' => 'recent', 'user_id' => 2, 'result' => 1, 'ip' => '10.0.0.2', 'time' => '2026-07-24 00:00:00'] +]; + +audit_poll_user_log(); +$recorded_usernames = []; + +foreach ($audit_auth_recorded_events as $event) { + $details = json_decode($event['params'][24], true); + + if (is_array($details) && isset($details['username'])) { + $recorded_usernames[] = $details['username']; + } +} + +audit_test_assert_true(!in_array('old', $recorded_usernames, true), 'Initial ingestion must not replay rows older than the retention cutoff.'); +audit_test_reset_state(); +$audit_auth_config['audit_retention'] = '90'; + +// --------------------------------------------------------------------------- +// 8. Brute-force: exact threshold, throttle boundary, concurrency +// --------------------------------------------------------------------------- + +audit_test_reset_state(); + +// Below threshold: no emit. +$audit_auth_failed_count = 9; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Below threshold must not emit.'); + +// Exactly at threshold: emit. +$audit_auth_failed_count = 10; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Exactly at threshold must emit even when the throttle setting row did not previously exist.'); +audit_test_assert_true(read_config_option('audit_brute_force_last_alert') !== '', 'Brute-force detection must initialize its throttle setting row.'); +audit_test_assert_same('cacti.auth.brute_force_suspected', $audit_auth_recorded_events[0]['params'][12], 'Brute-force event type must be correct.'); +audit_test_assert_same('critical', $audit_auth_recorded_events[0]['params'][14], 'Brute-force must be critical.'); + +// Within window: throttled (atomic UPDATE claims nothing). +$audit_auth_failed_count = 12; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Within the window, the atomic claim must throttle the second alert.'); + +// Concurrent check: second poller's UPDATE affects 0 rows. +$audit_auth_failed_count = 12; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'A concurrent poller must not emit a duplicate alert.'); + +// Failed audit insert releases the slot. +audit_test_reset_state(); +$audit_auth_settings_rows['audit_brute_force_last_alert'] = ''; +$audit_auth_failed_count = 10; +$audit_auth_insert_fails = true; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same('', $audit_auth_settings_rows['audit_brute_force_last_alert'] ?? '', 'A failed audit insert must release the alert slot for retry.'); +$audit_auth_insert_fails = false; + +// Disabled must not emit. +$audit_auth_config['audit_brute_force_enabled'] = 'off'; +$audit_auth_failed_count = 50; +$audit_auth_settings_rows['audit_brute_force_last_alert'] = ''; +$audit_auth_recorded_events = []; +audit_detect_brute_force(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Disabled brute-force detection must not emit.'); +$audit_auth_config['audit_brute_force_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 9. custom_denied: returns mode, records event, redacts referer +// --------------------------------------------------------------------------- + +$_SESSION['sess_user_id'] = 5; +$_SERVER['SCRIPT_NAME'] = '/cacti/host.php'; +$_SERVER['HTTP_REFERER'] = 'https://cacti.example.com/index.php?token=secret&reset_hash=abc123'; +audit_test_reset_state(); + +$returned = audit_custom_denied('OPER_MODE_NATIVE'); +audit_test_assert_same('OPER_MODE_NATIVE', $returned, 'audit_custom_denied() must return the input mode unchanged.'); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'audit_custom_denied() must record one event.'); +audit_test_assert_same('cacti.auth.authorization.denied', $audit_auth_recorded_events[0]['params'][12], 'Denied event type must be correct.'); +$details_json = $audit_auth_recorded_events[0]['params'][24]; +$details = json_decode($details_json, true); +audit_test_assert_same('https://cacti.example.com/index.php', $details['referer_origin'], 'Referer query string must be stripped.'); +audit_test_assert_true(strpos($details_json, 'secret') === false, 'The referer token must not appear in details.'); +audit_test_assert_true(strpos($details_json, 'abc123') === false, 'The reset hash must not appear in details.'); + +// Disabled must not record but still return the mode. +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +$audit_auth_recorded_events = []; +audit_test_assert_same('OPER_MODE_NATIVE', audit_custom_denied('OPER_MODE_NATIVE'), 'audit_custom_denied() must always return the input mode.'); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'audit_custom_denied() must skip recording when disabled.'); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 10. Logout: master switch gates pre-destroy; post-destroy uses stash +// --------------------------------------------------------------------------- + +$_SESSION['sess_user_id'] = 5; +$_REQUEST['action'] = 'user'; +audit_test_reset_state(); + +audit_logout_pre_session_destroy(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Pre-destroy must record the logout event.'); +audit_test_assert_same('authentication.logout', $audit_auth_recorded_events[0]['params'][12], 'Pre-destroy event type must be correct.'); + +$audit_auth_recorded_events = []; +audit_logout_post_session_destroy(); +audit_test_assert_same(1, count($audit_auth_recorded_events), 'Post-destroy must record one completed event.'); +audit_test_assert_same('authentication.logout.completed', $audit_auth_recorded_events[0]['params'][12], 'Post-destroy event type must be correct.'); +audit_test_assert_same(5, $audit_auth_recorded_events[0]['params'][1], 'Post-destroy must carry the stashed user_id.'); + +// Empty stash: no record. +$audit_auth_recorded_events = []; +audit_logout_post_session_destroy(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Post-destroy must not record when the stash is empty.'); + +// Master switch off: pre-destroy must not record. +$audit_auth_config['audit_auth_log_enabled'] = 'off'; +$audit_auth_recorded_events = []; +audit_logout_pre_session_destroy(); +audit_test_assert_same(0, count($audit_auth_recorded_events), 'Pre-destroy must not record when auth auditing is disabled.'); +$audit_auth_config['audit_auth_log_enabled'] = 'on'; + +// --------------------------------------------------------------------------- +// 11. Uninstall lifecycle: shutdown callbacks tolerate removed tables +// --------------------------------------------------------------------------- + +audit_test_reset_state(); +$audit_auth_log_exists = false; + +audit_test_assert_same(0, audit_record_event('audit.test.after_uninstall'), 'Recording must no-op after audit_log is removed.'); +audit_finalize_request(123); +audit_deliver_external_event(123); +audit_retry_external_logs(); +audit_test_assert_same([], $audit_auth_executed_sql, 'Late callbacks must not query audit_log after plugin uninstall.'); + +// --------------------------------------------------------------------------- +// 12. Unauthorized auth-settings save uses the generic event type +// --------------------------------------------------------------------------- + +// audit_enforce_syslog_settings_request() reads POST via filter_input_array +// (a PHP built-in reading the real SAPI POST body) and calls exit on 403, +// so it cannot be exercised behaviorally in a CLI test. Verify via a static +// source assertion that the auth-only unauthorized path emits the generic +// audit.configuration.denied event, distinct from the syslog-specific one. +$functions_source = file_get_contents(dirname(__DIR__) . '/audit_functions.php'); + +if (!is_string($functions_source)) { + fwrite(STDERR, 'Unable to read audit_functions.php for settings authorization checks.' . PHP_EOL); + exit(1); +} + +audit_test_assert_true( + strpos($functions_source, "'audit.configuration.denied'") !== false, + 'The generic audit.configuration.denied event must be present for unauthorized auth-settings saves.' +); +audit_test_assert_true( + strpos($functions_source, "'audit.syslog.configuration.denied'") !== false, + 'The syslog-specific denied event must remain for unauthorized syslog-settings saves.' +); +// Confirm the auth-only branch exists (else branch after the syslog check). +audit_test_assert_true( + strpos($functions_source, 'audit.configuration.denied') !== false + && strpos($functions_source, 'audit_admin_required') !== false, + 'The unauthorized auth-settings save must record an audit_admin_required denied event.' +); +audit_test_assert_true( + preg_match('/\\$name\\s*===\\s*[\'"]audit_user_log_batch_size[\'"]/', $functions_source) === 1, + 'The user_log ingestion batch-size setting must receive the same Audit Log Admin protection as authentication settings.' +); + +print "Auth audit tests passed.\n"; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b701e11..e268ffd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,43 +12,45 @@ * can be loaded in isolation without the full Cacti application. */ -$GLOBALS['__test_db_calls'] = array(); +$GLOBALS['__test_db_calls'] = []; if (!function_exists('db_execute')) { function db_execute($sql) { - $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute', 'sql' => $sql, 'params' => array()); + $GLOBALS['__test_db_calls'][] = ['fn' => 'db_execute', 'sql' => $sql, 'params' => []]; + return true; } } if (!function_exists('db_execute_prepared')) { - function db_execute_prepared($sql, $params = array()) { - $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params); + function db_execute_prepared($sql, $params = []) { + $GLOBALS['__test_db_calls'][] = ['fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params]; + return true; } } if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { - return array(); + return []; } } if (!function_exists('db_fetch_assoc_prepared')) { - function db_fetch_assoc_prepared($sql, $params = array()) { - return array(); + function db_fetch_assoc_prepared($sql, $params = []) { + return []; } } if (!function_exists('db_fetch_row')) { function db_fetch_row($sql) { - return array(); + return []; } } if (!function_exists('db_fetch_row_prepared')) { - function db_fetch_row_prepared($sql, $params = array()) { - return array(); + function db_fetch_row_prepared($sql, $params = []) { + return []; } } @@ -59,7 +61,7 @@ function db_fetch_cell($sql) { } if (!function_exists('db_fetch_cell_prepared')) { - function db_fetch_cell_prepared($sql, $params = array()) { + function db_fetch_cell_prepared($sql, $params = []) { return ''; } } diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 2f445ae..f8ea35c 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -5,19 +5,27 @@ $javascript = file_get_contents(dirname(__DIR__) . '/js/functions.js'); $setup = file_get_contents(dirname(__DIR__) . '/setup.php'); -$required_controller_guards = array( +$required_controller_guards = [ "\$_SERVER['REQUEST_METHOD'] !== 'POST'", 'audit_user_is_admin()', 'csrf_check(false)', 'html_escape($data', "__('Outcome Reason:', 'audit')", "header('Content-Type: text/csv; charset=UTF-8')", - "fputcsv(", + 'fputcsv(', "case 'syslog_test':", "case 'syslog_retry':", 'audit_syslog_test_delivery()', - 'audit_syslog_retry_dead_letters($delivery_ids)' -); + 'audit_syslog_retry_dead_letters($delivery_ids)', + 'if (!is_array($data) || $data === [])', + "if (!audit_syslog_enabled()) {\n\t\treturn;", + "if (audit_syslog_enabled() && db_table_exists('audit_syslog_delivery'))", + 'cacti_sizeof($syslog) > 0', + '$syslog[\'state\'] ?? \'unknown\'', + '$syslog[\'attempts\'] ?? 0', + '$syslog[\'sent_time\'] ?? \'\'', + '$syslog[\'last_error\'] ?? \'\'' +]; foreach ($required_controller_guards as $guard) { if (strpos($controller, $guard) === false) { @@ -26,13 +34,13 @@ } } -$required_schema_fragments = array( +$required_schema_fragments = [ "'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'))", + "auth_augment_roles(__('Audit Plugin', 'audit'), ['audit.php', 'audit_manage.php'])", 'api_plugin_register_hook(\'audit\', \'replicate_out\'', 'request_status', 'ADD COLUMN IF NOT EXISTS external_status', @@ -41,13 +49,27 @@ 'audit_retry_external_logs()', 'audit_process_syslog_queue()', 'logout_pre_session_destroy', + 'logout_post_session_destroy', + 'custom_denied', + 'audit_poll_user_log()', + 'audit_detect_brute_force()', + 'audit_auth_log_enabled', + 'audit_brute_force_enabled', + 'audit_user_log_batch_size', + 'array_merge($temp, $auth_settings, $syslog)', + "'audit_user_log_batch_size' => '1000'", + 'audit_persist_auth_defaults', + 'CREATE TABLE IF NOT EXISTS `audit_user_log_state`', + 'DROP TABLE IF EXISTS audit_user_log_state', + "'DELETE FROM settings WHERE LEFT(name, 6) = ?'", + "['audit_']", 'event_uuid char(36)', 'operation_outcome', 'external_attempts', 'CREATE TABLE IF NOT EXISTS `audit_syslog_delivery`', 'DROP TABLE IF EXISTS audit_syslog_delivery', - "auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'))" -); + "auth_augment_roles(__('Audit Plugin', 'audit'), ['audit.php', 'audit_manage.php'])" +]; foreach ($required_schema_fragments as $fragment) { if (strpos($setup, $fragment) === false) { @@ -56,12 +78,43 @@ } } -$required_verifier_fragments = array( +$required_verifier_fragments = [ 'audit_operation_verifier_for_request', "'user_realm_permissions'", "'realm_permissions_verified'", "register_shutdown_function('audit_finalize_request', \$audit_id, \$started_at, \$verifier)" -); +]; + +$required_auth_fragments = [ + 'function audit_poll_user_log', + 'function audit_detect_brute_force', + 'function audit_custom_denied', + 'function audit_logout_post_session_destroy', + 'function audit_user_log_event_descriptor', + "'cacti.auth.login.failed'", + "'cacti.auth.login.credentials_accepted'", + "'cacti.auth.login.token'", + "'cacti.auth.password.changed'", + "'cacti.auth.password_change_or_2fa_failed'", + "'cacti.auth.login.unknown'", + "'cacti.auth.brute_force_suspected'", + "'cacti.auth.authorization.denied'", + "'authentication.logout.completed'", + "'audit.configuration.denied'", + "'START TRANSACTION'", + "'ROLLBACK'", + "'COMMIT'", + "'defer_delivery'", + 'FROM audit_user_log_state AS auls', + 'INSERT IGNORE INTO settings (name, value)' +]; + +foreach ($required_auth_fragments as $fragment) { + if (strpos($functions, $fragment) === false) { + fwrite(STDERR, 'Missing authentication audit requirement: ' . $fragment . PHP_EOL); + exit(1); + } +} foreach ($required_verifier_fragments as $fragment) { if (strpos($functions, $fragment) === false) { @@ -91,7 +144,7 @@ } if (strpos($functions, 'audit_enforce_syslog_settings_request()') === false || - strpos($functions, "'audit.syslog.configuration.denied'") === false) { + strpos($functions, "'audit.syslog.configuration.denied'") === false) { fwrite(STDERR, 'Remote Syslog settings must enforce Audit Log Admin on save.' . PHP_EOL); exit(1); } @@ -107,7 +160,7 @@ } if (strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_test") === false || - strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_retry") === false) { + strpos($javascript, "loadPageUsingPost('audit.php?action=syslog_retry") === false) { fwrite(STDERR, 'Syslog administration actions must use POST request paths.' . PHP_EOL); exit(1); } diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php index 4a736a5..2085182 100644 --- a/tests/security_functions_test.php +++ b/tests/security_functions_test.php @@ -2,10 +2,10 @@ require_once dirname(__DIR__) . '/audit_functions.php'; -$audit_test_realms = array(); -$audit_test_existing_users = array(); -$audit_test_user_realms = array(); -$audit_test_user_query_failure = false; +$audit_test_realms = []; +$audit_test_existing_users = []; +$audit_test_user_realms = []; +$audit_test_user_query_failure = false; $audit_test_realm_query_failure = false; function api_plugin_user_realm_auth($filename = '') { @@ -14,7 +14,7 @@ function api_plugin_user_realm_auth($filename = '') { return !empty($audit_test_realms[$filename]); } -function db_fetch_cell_prepared($sql, $params = array()) { +function db_fetch_cell_prepared($sql, $params = []) { global $audit_test_existing_users, $audit_test_user_query_failure; if ($audit_test_user_query_failure) { @@ -26,18 +26,18 @@ function db_fetch_cell_prepared($sql, $params = array()) { return in_array($user_id, $audit_test_existing_users, true) ? 1 : 0; } -function db_fetch_assoc_prepared($sql, $params = array()) { +function db_fetch_assoc_prepared($sql, $params = []) { global $audit_test_user_realms, $audit_test_realm_query_failure; if ($audit_test_realm_query_failure) { return false; } - $user_id = (int) ($params[0] ?? 0); - $realm_ids = $audit_test_user_realms[$user_id] ?? array(); + $user_id = (int) ($params[0] ?? 0); + $realm_ids = $audit_test_user_realms[$user_id] ?? []; - return array_map(function($realm_id) { - return array('realm_id' => $realm_id); + return array_map(function ($realm_id) { + return ['realm_id' => $realm_id]; }, $realm_ids); } @@ -53,90 +53,90 @@ function audit_test_assert_same($expected, $actual, $message) { audit_test_assert_same(false, audit_user_is_admin(), 'Audit users must not be treated as audit administrators.'); $audit_test_realms['audit_manage.php'] = true; audit_test_assert_same(true, audit_user_is_admin(), 'Audit plugin administrators must be able to purge.'); -$audit_test_realms = array(); +$audit_test_realms = []; -$verifier = audit_operation_verifier_for_request('user_admin.php', array( - 'action' => 'save', - 'id' => '4', +$verifier = audit_operation_verifier_for_request('user_admin.php', [ + 'action' => 'save', + 'id' => '4', 'save_component_realm_perms' => '1', - 'section110' => 'on', - 'section106' => 'on' -)); + 'section110' => 'on', + 'section106' => 'on' +]); audit_test_assert_same( - array( - 'type' => 'user_realm_permissions', - 'target_user_id' => 4, - 'expected_realm_ids' => array(106, 110) - ), + [ + 'type' => 'user_realm_permissions', + 'target_user_id' => 4, + 'expected_realm_ids' => [106, 110] + ], $verifier, 'User realm permission saves must capture a normalized post-condition verifier.' ); audit_test_assert_same( null, - audit_operation_verifier_for_request('host.php', array('id' => '4')), + audit_operation_verifier_for_request('host.php', ['id' => '4']), 'Unrelated requests must not receive a user realm verifier.' ); -$invalid_verifier = audit_operation_verifier_for_request('user_admin.php', array( - 'id' => 'invalid', +$invalid_verifier = audit_operation_verifier_for_request('user_admin.php', [ + 'id' => 'invalid', 'save_component_realm_perms' => '1' -)); +]); audit_test_assert_same( 'invalid', $invalid_verifier['type'], 'Invalid realm permission requests must not be verified as successful.' ); -$audit_test_existing_users = array(4); -$audit_test_user_realms = array(4 => array(110, 106)); +$audit_test_existing_users = [4]; +$audit_test_user_realms = [4 => [110, 106]]; audit_test_assert_same( - array('outcome' => 'success', 'reason' => 'realm_permissions_verified'), + ['outcome' => 'success', 'reason' => 'realm_permissions_verified'], audit_verify_operation($verifier), 'Matching stored realm permissions must produce a verified success outcome.' ); -$audit_test_user_realms = array(4 => array(106)); +$audit_test_user_realms = [4 => [106]]; audit_test_assert_same( - array('outcome' => 'failure', 'reason' => 'realm_permissions_mismatch'), + ['outcome' => 'failure', 'reason' => 'realm_permissions_mismatch'], audit_verify_operation($verifier), 'Mismatched stored realm permissions must produce a failure outcome.' ); -$audit_test_existing_users = array(); +$audit_test_existing_users = []; audit_test_assert_same( - array('outcome' => 'failure', 'reason' => 'target_user_not_found'), + ['outcome' => 'failure', 'reason' => 'target_user_not_found'], audit_verify_operation($verifier), 'A missing target user must produce a failure outcome.' ); -$audit_test_existing_users = array(4); +$audit_test_existing_users = [4]; $audit_test_user_query_failure = true; audit_test_assert_same( - array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'), + ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'], audit_verify_operation($verifier), 'A failed target-user query must preserve an unknown outcome.' ); $audit_test_user_query_failure = false; -$audit_test_existing_users = array(4); +$audit_test_existing_users = [4]; $audit_test_realm_query_failure = true; audit_test_assert_same( - array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'), + ['outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'], audit_verify_operation($verifier), 'A failed verification query must preserve an unknown outcome.' ); $audit_test_realm_query_failure = false; -$request = array( +$request = [ 'username' => 'operator', 'password' => 'top-secret', - 'nested' => array( - 'api_token' => 'nested-secret', - 'description' => '', + 'nested' => [ + 'api_token' => 'nested-secret', + 'description' => '', 'opaque_value' => 'Bearer abcdefghijklmnopqrstuvwxyz' - ) -); + ] +]; $redacted = audit_redact_sensitive_data($request); @@ -149,14 +149,14 @@ function audit_test_assert_same($expected, $actual, $message) { 'Non-secret data must remain available for later context-aware output escaping.' ); -$arguments = audit_redact_cli_arguments(array( +$arguments = audit_redact_cli_arguments([ 'cli/example.php', '--password=secret-one', '--api-token', 'secret-two', 'https://user:secret-three@example.com/path', '--description=test' -)); +]); audit_test_assert_same('--password=[REDACTED]', $arguments[1], 'Inline CLI passwords must be redacted.'); audit_test_assert_same('[REDACTED]', $arguments[3], 'Separate CLI secret values must be redacted.'); @@ -168,15 +168,17 @@ function audit_test_assert_same($expected, $actual, $message) { audit_test_assert_same("'=1+1", audit_csv_safe_cell('=1+1'), 'Spreadsheet formulas must be neutralized.'); -$deep = array(); +$deep = []; $cursor = &$deep; + for ($i = 0; $i < 15; $i++) { - $cursor['nested'] = array(); - $cursor = &$cursor['nested']; + $cursor['nested'] = []; + $cursor = &$cursor['nested']; } unset($cursor); $bounded_json = audit_json_encode($deep); + if (strpos($bounded_json, 'MAXIMUM DEPTH REACHED') === false) { fwrite(STDERR, 'Deep request structures must be bounded.' . PHP_EOL); exit(1); @@ -184,6 +186,7 @@ function audit_test_assert_same($expected, $actual, $message) { $invalid_json = audit_json_decode('{invalid', $json_error); audit_test_assert_same(null, $invalid_json, 'Malformed JSON must return a controlled null result.'); + if ($json_error === null || $json_error === '') { fwrite(STDERR, 'Malformed JSON must return a useful error.' . PHP_EOL); exit(1); @@ -204,11 +207,12 @@ function audit_test_assert_same($expected, $actual, $message) { ); audit_test_assert_same( 'failed', - audit_request_status(array('type' => E_ERROR), 200), + audit_request_status(['type' => E_ERROR], 200), 'Fatal errors must finalize as failed requests.' ); $uuid = audit_uuid_v4(); + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid)) { fwrite(STDERR, 'Event identifiers must be RFC 4122 version 4 UUIDs.' . PHP_EOL); exit(1); @@ -225,38 +229,39 @@ function audit_test_assert_same($expected, $actual, $message) { 'Requests without a specific action must use the submitted event verb.' ); -$hash_event = array( - 'event_uuid' => $uuid, - 'correlation_id' => audit_uuid_v4(), - 'event_type' => 'cacti.test.completed', - 'user_id' => 1, - 'action' => 'test', - 'event_time' => '2026-07-24 10:00:00', +$hash_event = [ + 'event_uuid' => $uuid, + 'correlation_id' => audit_uuid_v4(), + 'event_type' => 'cacti.test.completed', + 'user_id' => 1, + 'action' => 'test', + 'event_time' => '2026-07-24 10:00:00', 'operation_outcome' => 'success', - 'details' => '{}' -); -$first_hash = audit_event_integrity_hash($hash_event); + 'details' => '{}' +]; +$first_hash = audit_event_integrity_hash($hash_event); $hash_event['operation_outcome'] = 'failure'; + if ($first_hash === audit_event_integrity_hash($hash_event)) { fwrite(STDERR, 'Integrity hashes must change when protected event fields change.' . PHP_EOL); exit(1); } -$external_record = array( - 'event_time' => '2026-07-24 10:00:00', - 'action' => "Update\nDevice", - 'post' => '{"id":42}', +$external_record = [ + 'event_time' => '2026-07-24 10:00:00', + 'action' => "Update\nDevice", + 'post' => '{"id":42}', 'object_data' => '[]' -); +]; $json_record = audit_external_log_format($external_record, 'json'); audit_test_assert_same("\n", substr($json_record, -1), 'JSON external records must end with a newline.'); audit_test_assert_same( - array( - 'event_time' => '2026-07-24 10:00:00', - 'action' => "Update\nDevice", - 'post' => array('id' => 42), - 'object_data' => array() - ), + [ + 'event_time' => '2026-07-24 10:00:00', + 'action' => "Update\nDevice", + 'post' => ['id' => 42], + 'object_data' => [] + ], json_decode(trim($json_record), true), 'JSON external records must expose stored JSON fields as native structures.' ); @@ -267,7 +272,7 @@ function audit_test_assert_same($expected, $actual, $message) { ); audit_test_assert_same( '{"post":"not-json"}' . "\n", - audit_external_log_format(array('post' => 'not-json'), 'json'), + audit_external_log_format(['post' => 'not-json'], 'json'), 'Malformed stored JSON fields must remain available as strings.' ); audit_test_assert_same( diff --git a/tests/syslog_functions_test.php b/tests/syslog_functions_test.php index 0adf595..5dae55e 100644 --- a/tests/syslog_functions_test.php +++ b/tests/syslog_functions_test.php @@ -13,59 +13,59 @@ function audit_syslog_test_assert($condition, $message) { } } -function audit_syslog_test_config($overrides = array()) { - $values = array( - 'receiver' => '127.0.0.1', - 'port' => '514', - 'transport' => 'udp', - 'format' => 'json', - 'facility' => 'local0', - 'application' => 'cacti-audit', - 'node_id' => 'cacti-node-1', - 'timeout' => '1', - 'udp_max_size' => '8192', - 'retry_base' => '5', - 'retry_max' => '60', - 'max_attempts' => '5', - 'batch_size' => '10', +function audit_syslog_test_config($overrides = []) { + $values = [ + 'receiver' => '127.0.0.1', + 'port' => '514', + 'transport' => 'udp', + 'format' => 'json', + 'facility' => 'local0', + 'application' => 'cacti-audit', + 'node_id' => 'cacti-node-1', + 'timeout' => '1', + 'udp_max_size' => '8192', + 'retry_base' => '5', + 'retry_max' => '60', + 'max_attempts' => '5', + 'batch_size' => '10', 'pending_age_warning' => '900', 'dead_letter_warning' => '1', - 'tls_ca_file' => '', - 'tls_client_cert' => '', - 'tls_client_key' => '' - ); + 'tls_ca_file' => '', + 'tls_client_cert' => '', + 'tls_client_key' => '' + ]; return audit_syslog_config(array_merge($values, $overrides)); } function audit_syslog_test_event() { - return array( - 'id' => 42, - 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', - 'correlation_id' => '48f486c3-90d0-45a2-a50d-3b31e571aa91', - 'event_type' => 'cacti.user_admin.save', - 'event_category' => 'identity_access', - 'severity' => 'info', - 'actor_type' => 'user', - 'page' => 'user_admin.php', - 'user_id' => 1, - 'action' => 'save', - 'request_status' => 'completed', + return [ + 'id' => 42, + 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', + 'correlation_id' => '48f486c3-90d0-45a2-a50d-3b31e571aa91', + 'event_type' => 'cacti.user_admin.save', + 'event_category' => 'identity_access', + 'severity' => 'info', + 'actor_type' => 'user', + 'page' => 'user_admin.php', + 'user_id' => 1, + 'action' => 'save', + 'request_status' => 'completed', 'operation_outcome' => 'success', - 'target_type' => 'user', - 'target_id' => '4', - 'ip_address' => '192.0.2.10', - 'event_time' => '2026-07-24 15:16:17.123456', - 'post' => '{"id":4,"description":"new value","password":"must-not-leak"}', - 'object_data' => '[{"id":4,"description":"old value"}]', - 'details' => '{"test":true}', - 'integrity_hash' => str_repeat('a', 64) - ); + 'target_type' => 'user', + 'target_id' => '4', + 'ip_address' => '192.0.2.10', + 'event_time' => '2026-07-24 15:16:17.123456', + 'post' => '{"id":4,"description":"new value","password":"must-not-leak"}', + 'object_data' => '[{"id":4,"description":"old value"}]', + 'details' => '{"test":true}', + 'integrity_hash' => str_repeat('a', 64) + ]; } function audit_syslog_test_udp_server(&$port, &$error, &$error_message) { for ($attempt = 0; $attempt < 20; $attempt++) { - $port = random_int(20000, 50000); + $port = random_int(20000, 50000); $server = @stream_socket_server( 'udp://127.0.0.1:' . $port, $error, @@ -83,6 +83,7 @@ function audit_syslog_test_udp_server(&$port, &$error, &$error_message) { function audit_syslog_test_tls_material() { $directory = sys_get_temp_dir() . '/audit-syslog-' . bin2hex(random_bytes(8)); + if (!mkdir($directory, 0700)) { return false; } @@ -100,16 +101,16 @@ function audit_syslog_test_tls_material() { "extendedKeyUsage=serverAuth\n"; file_put_contents($config_path, $config_text); - $options = array( - 'config' => $config_path, - 'digest_alg' => 'sha256', + $options = [ + 'config' => $config_path, + 'digest_alg' => 'sha256', 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, - 'req_extensions' => 'v3_req', - 'x509_extensions' => 'v3_req' - ); - $key = openssl_pkey_new($options); - $csr = $key === false ? false : openssl_csr_new(array('commonName' => '127.0.0.1'), $key, $options); + 'req_extensions' => 'v3_req', + 'x509_extensions' => 'v3_req' + ]; + $key = openssl_pkey_new($options); + $csr = $key === false ? false : openssl_csr_new(['commonName' => '127.0.0.1'], $key, $options); $certificate = $csr === false ? false : openssl_csr_sign($csr, null, $key, 1, $options); if ($certificate === false || @@ -118,33 +119,33 @@ function audit_syslog_test_tls_material() { return false; } - $ca_path = $directory . '/ca.pem'; + $ca_path = $directory . '/ca.pem'; $server_path = $directory . '/server.pem'; $result_path = $directory . '/received.log'; file_put_contents($ca_path, $certificate_pem); file_put_contents($server_path, $certificate_pem . $key_pem); chmod($server_path, 0600); - return array( + return [ 'directory' => $directory, - 'config' => $config_path, - 'ca' => $ca_path, - 'server' => $server_path, - 'result' => $result_path - ); + 'config' => $config_path, + 'ca' => $ca_path, + 'server' => $server_path, + 'result' => $result_path + ]; } function audit_syslog_test_tls_server($material, &$port) { - $context = stream_context_create(array( - 'ssl' => array( - 'local_cert' => $material['server'], - 'verify_peer' => false, + $context = stream_context_create([ + 'ssl' => [ + 'local_cert' => $material['server'], + 'verify_peer' => false, 'allow_self_signed' => true - ) - )); - $error = 0; + ] + ]); + $error = 0; $error_message = ''; - $server = stream_socket_server( + $server = stream_socket_server( 'tls://127.0.0.1:0', $error, $error_message, @@ -163,7 +164,7 @@ function audit_syslog_test_tls_server($material, &$port) { } function audit_syslog_test_remove_tls_material($material) { - foreach (array('result', 'server', 'ca', 'config') as $name) { + foreach (['result', 'server', 'ca', 'config'] as $name) { if (is_file($material[$name])) { unlink($material[$name]); } @@ -177,22 +178,24 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($config['tls_verify_peer'] === true, 'TLS peer verification must always be enabled.'); audit_syslog_test_assert($config['tls_verify_peer_name'] === true, 'TLS hostname verification must always be enabled.'); audit_syslog_test_assert( - audit_syslog_test_config(array('transport' => 'udp', 'port' => ''))['port'] === 514, + audit_syslog_test_config(['transport' => 'udp', 'port' => ''])['port'] === 514, 'A blank UDP port must use the standard default of 514.' ); audit_syslog_test_assert( - audit_syslog_test_config(array('transport' => 'tls', 'port' => ''))['port'] === 6514, + audit_syslog_test_config(['transport' => 'tls', 'port' => ''])['port'] === 6514, 'A blank TLS port must use the standard default of 6514.' ); $outer_warning_called = false; -$captured_warning = ''; -set_error_handler(function() use (&$outer_warning_called) { +$captured_warning = ''; +set_error_handler(function () use (&$outer_warning_called) { $outer_warning_called = true; + return true; }); -$warning_result = audit_syslog_stream_operation(function() { +$warning_result = audit_syslog_stream_operation(function () { trigger_error("expected stream warning\nwith control text", E_USER_WARNING); + return false; }, $captured_warning); restore_error_handler(); @@ -201,10 +204,10 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($captured_warning === 'expected stream warning with control text', 'Captured stream warnings must be normalized into a bounded one-line delivery error.'); -$invalid = audit_syslog_test_config(array('receiver' => 'udp://user:secret@example.test')); +$invalid = audit_syslog_test_config(['receiver' => 'udp://user:secret@example.test']); audit_syslog_test_assert(!$invalid['valid'], 'Receiver URIs and embedded credentials must be rejected.'); -$event = audit_syslog_test_event(); +$event = audit_syslog_test_event(); $formatted = audit_syslog_record($event, $config); audit_syslog_test_assert($formatted['status'] === 'ready', 'A valid event must produce a Syslog record.'); audit_syslog_test_assert(strpos($formatted['record'], '<134>1 2026-07-24T15:16:17.123456Z cacti-node-1 cacti-audit 7 cacti.user_admin.save ') === 0, @@ -222,8 +225,8 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert(audit_syslog_frame($formatted['record'], 'udp') === $formatted['record'], 'UDP must send one unframed RFC 5424 record per datagram.'); -$cef_config = audit_syslog_test_config(array('format' => 'cef')); -$cef = audit_syslog_record($event, $cef_config); +$cef_config = audit_syslog_test_config(['format' => 'cef']); +$cef = audit_syslog_record($event, $cef_config); audit_syslog_test_assert(strpos($cef['record'], 'CEF:0|Cacti|Audit Plugin|1.5|cacti.user_admin.save|save|3|') !== false, 'CEF payloads must contain normalized vendor, product, event, action, and severity fields.'); audit_syslog_test_assert(strpos($cef['record'], 'externalId=32e0a97d-d9e8-4abc-8f41-2bbbc50793ca') !== false, @@ -237,21 +240,21 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert(strpos($cef['record'], 'must-not-leak') === false, 'CEF payloads must defensively redact sensitive submitted fields.'); -$rfc_config = audit_syslog_test_config(array('format' => 'rfc5424')); -$rfc = audit_syslog_record($event, $rfc_config); +$rfc_config = audit_syslog_test_config(['format' => 'rfc5424']); +$rfc = audit_syslog_record($event, $rfc_config); audit_syslog_test_assert(strpos($rfc['record'], 'Audit event 32e0a97d-d9e8-4abc-8f41-2bbbc50793ca') !== false, 'RFC 5424-only payloads must remain identifiable.'); -$escaped_event = $event; +$escaped_event = $event; $escaped_event['target_id'] = "value\\\"]\nnext"; -$escaped = audit_syslog_record($escaped_event, $config); +$escaped = audit_syslog_record($escaped_event, $config); audit_syslog_test_assert(strpos($escaped['record'], 'targetId="value\\\\\\"\\] next"') !== false, 'RFC 5424 structured data must escape backslashes, quotes, brackets, and control characters.'); -$small_udp = audit_syslog_test_config(array('udp_max_size' => '512')); -$large_event = $event; -$large_event['details'] = audit_json_encode(array('value' => str_repeat('x', 1024))); -$oversized = audit_syslog_record($large_event, $small_udp); +$small_udp = audit_syslog_test_config(['udp_max_size' => '512']); +$large_event = $event; +$large_event['details'] = audit_json_encode(['value' => str_repeat('x', 1024)]); +$oversized = audit_syslog_record($large_event, $small_udp); audit_syslog_test_assert($oversized['error_code'] === 'udp_message_too_large' && $oversized['permanent'], 'Oversized UDP events must fail permanently without truncation or splitting.'); @@ -259,12 +262,12 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($formatted['record'] === $again['record'], 'Repeated formatting of one event must preserve receiver deduplication identity.'); -$udp_error = 0; +$udp_error = 0; $udp_error_message = ''; -$udp_port = 0; -$udp_server = audit_syslog_test_udp_server($udp_port, $udp_error, $udp_error_message); +$udp_port = 0; +$udp_server = audit_syslog_test_udp_server($udp_port, $udp_error, $udp_error_message); audit_syslog_test_assert(is_resource($udp_server), 'The UDP integration-test receiver must start.'); -$udp_config = audit_syslog_test_config(array('port' => (string) $udp_port)); +$udp_config = audit_syslog_test_config(['port' => (string) $udp_port]); $udp_socket = null; $udp_result = audit_syslog_send_event($event, $udp_config, $udp_socket); audit_syslog_test_assert($udp_result['status'] === 'sent_unconfirmed', @@ -273,18 +276,19 @@ function audit_syslog_test_remove_tls_material($material) { $udp_received = fread($udp_server, 65535); audit_syslog_test_assert($udp_received === audit_syslog_record($event, $udp_config)['record'], 'UDP transport must deliver exactly one complete untruncated record.'); + if (is_resource($udp_socket)) { fclose($udp_socket); } fclose($udp_server); -$tcp_error = 0; +$tcp_error = 0; $tcp_error_message = ''; -$tcp_server = stream_socket_server('tcp://127.0.0.1:0', $tcp_error, $tcp_error_message); +$tcp_server = stream_socket_server('tcp://127.0.0.1:0', $tcp_error, $tcp_error_message); audit_syslog_test_assert(is_resource($tcp_server), 'The TCP integration-test receiver must start.'); -$tcp_name = stream_socket_get_name($tcp_server, false); -$tcp_port = (int) substr($tcp_name, strrpos($tcp_name, ':') + 1); -$tcp_config = audit_syslog_test_config(array('transport' => 'tcp', 'port' => (string) $tcp_port)); +$tcp_name = stream_socket_get_name($tcp_server, false); +$tcp_port = (int) substr($tcp_name, strrpos($tcp_name, ':') + 1); +$tcp_config = audit_syslog_test_config(['transport' => 'tcp', 'port' => (string) $tcp_port]); $tcp_socket = null; $tcp_result = audit_syslog_send_event($event, $tcp_config, $tcp_socket); audit_syslog_test_assert($tcp_result['status'] === 'sent_unconfirmed', @@ -297,6 +301,7 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($tcp_received === $expected_tcp, 'TCP transport must deliver one complete RFC 6587 octet-counted record.'); fclose($tcp_peer); + if (is_resource($tcp_socket)) { fclose($tcp_socket); } @@ -310,14 +315,16 @@ function audit_syslog_test_remove_tls_material($material) { socket_set_option($native_socket, SOL_SOCKET, SO_SNDBUF, 1024); stream_set_timeout($pair[0], 2); $partial_payload = str_repeat('p', 262144); - $partial_pid = pcntl_fork(); + $partial_pid = pcntl_fork(); audit_syslog_test_assert($partial_pid >= 0, 'The partial-write test must fork a local reader.'); if ($partial_pid === 0) { fclose($pair[0]); $received_length = 0; + while ($received_length < strlen($partial_payload)) { $chunk = fread($pair[1], 8192); + if ($chunk === false || $chunk === '') { break; } @@ -332,16 +339,16 @@ function audit_syslog_test_remove_tls_material($material) { fclose($pair[0]); pcntl_waitpid($partial_pid, $partial_status); audit_syslog_test_assert($partial_result['status'] === 'sent_unconfirmed' && - pcntl_wexitstatus($partial_status) === 0, + pcntl_wexitstatus($partial_status) === 0, 'TCP writes must loop until a record is complete when the socket accepts partial chunks.'); - $timeout_pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + $timeout_pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); $timeout_socket = socket_import_stream($timeout_pair[0]); socket_set_option($timeout_socket, SOL_SOCKET, SO_SNDBUF, 1024); stream_set_timeout($timeout_pair[0], 0, 100000); $timeout_result = audit_syslog_write($timeout_pair[0], str_repeat('t', 4194304), 'tcp'); audit_syslog_test_assert($timeout_result['status'] === 'failed' && - $timeout_result['error_code'] === 'write_failed', + $timeout_result['error_code'] === 'write_failed', 'A blocked stream must fail with a bounded write timeout instead of hanging indefinitely.'); fclose($timeout_pair[0]); fclose($timeout_pair[1]); @@ -350,7 +357,7 @@ function audit_syslog_test_remove_tls_material($material) { if (extension_loaded('openssl') && function_exists('pcntl_fork')) { $tls_material = audit_syslog_test_tls_material(); audit_syslog_test_assert(is_array($tls_material), 'The TLS integration test must create temporary certificate material.'); - $tls_port = 0; + $tls_port = 0; $tls_server = audit_syslog_test_tls_server($tls_material, $tls_port); audit_syslog_test_assert(is_resource($tls_server), 'The TLS integration-test receiver must start.'); $tls_pid = pcntl_fork(); @@ -358,6 +365,7 @@ function audit_syslog_test_remove_tls_material($material) { if ($tls_pid === 0) { $tls_peer = @stream_socket_accept($tls_server, 5); + if (is_resource($tls_peer)) { stream_set_timeout($tls_peer, 2); $tls_received = fread($tls_peer, 262144); @@ -370,13 +378,14 @@ function audit_syslog_test_remove_tls_material($material) { } fclose($tls_server); - $tls_config = audit_syslog_test_config(array( - 'transport' => 'tls', - 'port' => (string) $tls_port, + $tls_config = audit_syslog_test_config([ + 'transport' => 'tls', + 'port' => (string) $tls_port, 'tls_ca_file' => $tls_material['ca'] - )); + ]); $tls_socket = null; $tls_result = audit_syslog_send_event($event, $tls_config, $tls_socket); + if (is_resource($tls_socket)) { fclose($tls_socket); } @@ -390,7 +399,7 @@ function audit_syslog_test_remove_tls_material($material) { audit_syslog_test_assert($tls_received === $expected_tls, 'TLS transport must deliver one complete RFC 6587 octet-counted record.'); - $untrusted_port = 0; + $untrusted_port = 0; $untrusted_server = audit_syslog_test_tls_server($tls_material, $untrusted_port); audit_syslog_test_assert(is_resource($untrusted_server), 'The untrusted TLS test receiver must start.'); $untrusted_pid = pcntl_fork(); @@ -398,6 +407,7 @@ function audit_syslog_test_remove_tls_material($material) { if ($untrusted_pid === 0) { $untrusted_peer = @stream_socket_accept($untrusted_server, 5); + if (is_resource($untrusted_peer)) { fclose($untrusted_peer); } @@ -405,41 +415,42 @@ function audit_syslog_test_remove_tls_material($material) { } fclose($untrusted_server); - $untrusted_config = audit_syslog_test_config(array( - 'transport' => 'tls', - 'port' => (string) $untrusted_port, + $untrusted_config = audit_syslog_test_config([ + 'transport' => 'tls', + 'port' => (string) $untrusted_port, 'tls_ca_file' => '' - )); + ]); $untrusted_socket = null; $untrusted_result = audit_syslog_send_event($event, $untrusted_config, $untrusted_socket); + if (is_resource($untrusted_socket)) { fclose($untrusted_socket); } pcntl_waitpid($untrusted_pid, $untrusted_status); audit_syslog_test_assert($untrusted_result['status'] === 'failed' && - $untrusted_result['error_code'] === 'connection_failed', + $untrusted_result['error_code'] === 'connection_failed', 'TLS delivery must fail when the receiver certificate is not trusted.'); audit_syslog_test_remove_tls_material($tls_material); } -$outage_error = 0; +$outage_error = 0; $outage_message = ''; -$outage_server = stream_socket_server('tcp://127.0.0.1:0', $outage_error, $outage_message); -$outage_name = stream_socket_get_name($outage_server, false); -$outage_port = (int) substr($outage_name, strrpos($outage_name, ':') + 1); +$outage_server = stream_socket_server('tcp://127.0.0.1:0', $outage_error, $outage_message); +$outage_name = stream_socket_get_name($outage_server, false); +$outage_port = (int) substr($outage_name, strrpos($outage_name, ':') + 1); fclose($outage_server); -$outage_config = audit_syslog_test_config(array('transport' => 'tcp', 'port' => (string) $outage_port)); +$outage_config = audit_syslog_test_config(['transport' => 'tcp', 'port' => (string) $outage_port]); $outage_socket = null; -$outage = audit_syslog_send_event($event, $outage_config, $outage_socket); +$outage = audit_syslog_send_event($event, $outage_config, $outage_socket); audit_syslog_test_assert($outage['status'] === 'failed' && $outage['error_code'] === 'connection_failed', 'An unavailable receiver must return a bounded transient connection failure.'); -$invalid_tls = audit_syslog_test_config(array( - 'transport' => 'tls', - 'port' => '6514', +$invalid_tls = audit_syslog_test_config([ + 'transport' => 'tls', + 'port' => '6514', 'tls_ca_file' => '/does/not/exist' -)); +]); audit_syslog_test_assert(!$invalid_tls['valid'] && in_array('tls_ca_file_invalid', $invalid_tls['errors'], true), 'An invalid TLS trust path must be rejected before a connection is attempted.'); diff --git a/tests/syslog_queue_test.php b/tests/syslog_queue_test.php index 775f161..eb3b4a9 100644 --- a/tests/syslog_queue_test.php +++ b/tests/syslog_queue_test.php @@ -1,27 +1,27 @@ 'on', - 'audit_syslog_receiver' => '127.0.0.1', - 'audit_syslog_port' => '514', - 'audit_syslog_transport' => 'udp', - 'audit_syslog_format' => 'json', - 'audit_syslog_facility' => 'local0', - 'audit_syslog_application' => 'cacti-audit', - 'audit_syslog_node_id' => 'queue-test-node', - 'audit_syslog_timeout' => '5', - 'audit_syslog_udp_max_size' => '8192', - 'audit_syslog_retry_base' => '5', - 'audit_syslog_retry_max' => '60', - 'audit_syslog_max_attempts' => '5', - 'audit_syslog_batch_size' => '10', +$audit_queue_settings = [ + 'audit_syslog_enabled' => 'on', + 'audit_syslog_receiver' => '127.0.0.1', + 'audit_syslog_port' => '514', + 'audit_syslog_transport' => 'udp', + 'audit_syslog_format' => 'json', + 'audit_syslog_facility' => 'local0', + 'audit_syslog_application' => 'cacti-audit', + 'audit_syslog_node_id' => 'queue-test-node', + 'audit_syslog_timeout' => '5', + 'audit_syslog_udp_max_size' => '8192', + 'audit_syslog_retry_base' => '5', + 'audit_syslog_retry_max' => '60', + 'audit_syslog_max_attempts' => '5', + 'audit_syslog_batch_size' => '10', 'audit_syslog_pending_age_warning' => '900', 'audit_syslog_dead_letter_warning' => '1', - 'audit_syslog_tls_ca_file' => '', - 'audit_syslog_tls_client_cert' => '', - 'audit_syslog_tls_client_key' => '' -); -$audit_queue_calls = array(); + 'audit_syslog_tls_ca_file' => '', + 'audit_syslog_tls_client_cert' => '', + 'audit_syslog_tls_client_key' => '' +]; +$audit_queue_calls = []; $audit_queue_affected_rows = 0; function read_config_option($name) { @@ -31,28 +31,30 @@ function read_config_option($name) { } function db_table_exists($table) { - return $table === 'audit_syslog_delivery'; + return in_array($table, ['audit_log', 'audit_syslog_delivery'], true); } -function db_fetch_row_prepared($sql, $params = array()) { - return array( - 'id' => (int) $params[0], - 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', +function db_fetch_row_prepared($sql, $params = []) { + return [ + 'id' => (int) $params[0], + 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', 'request_status' => 'completed' - ); + ]; } -function db_execute_prepared($sql, $params = array()) { +function db_execute_prepared($sql, $params = []) { global $audit_queue_calls; - $audit_queue_calls[] = array('sql' => $sql, 'params' => $params); + $audit_queue_calls[] = ['sql' => $sql, 'params' => $params]; + return true; } function db_execute($sql) { global $audit_queue_calls; - $audit_queue_calls[] = array('sql' => $sql, 'params' => array()); + $audit_queue_calls[] = ['sql' => $sql, 'params' => []]; + return true; } @@ -87,11 +89,11 @@ function audit_queue_assert($condition, $message) { audit_queue_assert($audit_queue_calls[0]['params'][5] === 'pending', 'A valid enabled destination must enqueue in pending state.'); -$config = audit_syslog_config(); -$retry_identity = audit_syslog_delivery_config($config, array( - 'delivery_node_id' => 'original-node', +$config = audit_syslog_config(); +$retry_identity = audit_syslog_delivery_config($config, [ + 'delivery_node_id' => 'original-node', 'delivery_poller_id' => '3' -)); +]); audit_queue_assert($retry_identity['node_id'] === 'original-node' && $retry_identity['poller_id'] === '3', 'Retries must use the node and poller identity captured when the event was queued.'); @@ -99,14 +101,14 @@ function audit_queue_assert($condition, $message) { audit_queue_assert(audit_syslog_retry_delay(2, $config) === 10, 'Retry delay must increase exponentially.'); audit_queue_assert(audit_syslog_retry_delay(10, $config) === 60, 'Retry delay must be capped by the configured maximum.'); -$audit_queue_calls = array(); -$delivery = array('delivery_id' => 9, 'attempts' => 0); -$failure = array( - 'status' => 'failed', - 'permanent' => false, +$audit_queue_calls = []; +$delivery = ['delivery_id' => 9, 'attempts' => 0]; +$failure = [ + 'status' => 'failed', + 'permanent' => false, 'error_code' => 'connection_failed', - 'error' => "receiver unavailable\nwith control text" -); + 'error' => "receiver unavailable\nwith control text" +]; audit_syslog_update_delivery($delivery, $failure, $config); audit_queue_assert($audit_queue_calls[0]['params'][0] === 'retry', 'A transient failure before maximum attempts must enter retry state.'); @@ -116,32 +118,32 @@ function audit_queue_assert($condition, $message) { audit_queue_assert(strpos($audit_queue_calls[0]['params'][5], "\n") === false, 'Stored delivery errors must be bounded to one safe line.'); -$audit_queue_calls = array(); +$audit_queue_calls = []; $delivery['attempts'] = 4; audit_syslog_update_delivery($delivery, $failure, $config); audit_queue_assert($audit_queue_calls[0]['params'][0] === 'dead_letter', 'The configured final failed attempt must enter dead-letter state.'); -$audit_queue_calls = array(); -$permanent = $failure; -$permanent['permanent'] = true; +$audit_queue_calls = []; +$permanent = $failure; +$permanent['permanent'] = true; $permanent['error_code'] = 'udp_message_too_large'; -$delivery['attempts'] = 0; +$delivery['attempts'] = 0; audit_syslog_update_delivery($delivery, $permanent, $config); audit_queue_assert($audit_queue_calls[0]['params'][0] === 'dead_letter', 'A permanent formatting failure must enter dead-letter immediately.'); -$audit_queue_calls = array(); -$success = array('status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '', 'permanent' => false); +$audit_queue_calls = []; +$success = ['status' => 'sent_unconfirmed', 'error_code' => '', 'error' => '', 'permanent' => false]; audit_syslog_update_delivery($delivery, $success, $config); audit_queue_assert(strpos($audit_queue_calls[0]['sql'], "state = 'sent_unconfirmed'") !== false, 'A complete socket write must enter sent_unconfirmed state.'); -$audit_queue_calls = array(); +$audit_queue_calls = []; $audit_queue_affected_rows = 2; -$retried = audit_syslog_retry_dead_letters(array('2', 2, 0, -1, '7')); +$retried = audit_syslog_retry_dead_letters(['2', 2, 0, -1, '7']); audit_queue_assert($retried === 2, 'Manual retry must report the affected row count.'); -audit_queue_assert($audit_queue_calls[0]['params'] === array(2, 7), +audit_queue_assert($audit_queue_calls[0]['params'] === [2, 7], 'Manual retry must accept only unique positive selected delivery IDs.'); audit_queue_assert(strpos($audit_queue_calls[0]['sql'], "WHERE state = 'dead_letter'") !== false, 'Manual retry must never reset a non-dead-letter delivery.');