diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 0e9994a..fcbd69d 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -32,6 +32,25 @@ on: - develop jobs: + php74-runtime-syntax: + name: PHP 7.4 advertised runtime syntax + runs-on: ubuntu-latest + + steps: + - name: Checkout audit Plugin + uses: actions/checkout@v4 + + - name: Install PHP 7.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + coverage: none + + - name: Lint runtime PHP + run: | + find . -path './.git' -prune -o -path './phpstan' -prune -o -path './tests' -prune \ + -o -name '*.php' -print0 | xargs -0 -n1 php -l + code-quality: runs-on: ubuntu-latest diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index befe032..219e341 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -43,7 +43,7 @@ jobs: services: mysql: - image: mysql:8.0 + image: mariadb:10.11.18 env: MYSQL_ROOT_PASSWORD: cactiroot MYSQL_DATABASE: cacti diff --git a/.phpstan.neon b/.phpstan.neon index 6087a8c..ae6f006 100644 --- a/.phpstan.neon +++ b/.phpstan.neon @@ -22,7 +22,7 @@ parameters: - '*.po' - '*.pot' scanFiles: - - phpstan/stubs/cacti.stubs.php + - phpstan/stubs/cacti.stub level: 8 treatPhpDocTypesAsCertain: false reportUnmatchedIgnoredErrors: false @@ -44,4 +44,4 @@ parameters: - identifier: booleanAnd.rightAlwaysFalse - identifier: booleanAnd.rightAlwaysTrue - identifier: property.notFound - - identifier: parameterByRef.unusedType \ No newline at end of file + - identifier: parameterByRef.unusedType diff --git a/CHANGELOG.md b/CHANGELOG.md index d56164f..90090fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ * feature: Finalize request outcomes and expose external-log delivery status * issue: Harden external file logging, retention, malformed records, and replication * issue#38: Graph Template table does not exist +* issue#66: Restore the advertised PHP 7.4 runtime floor and fail-closed redaction * issue: If the audit log does not exist or is not set, set it and create it * issue: Audit assumes that all selected_items are numeric resulting in fatal error * feature: Support for Cacti 1.3 diff --git a/README.md b/README.md index cbffe64..b900f16 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # audit +The plugin supports Cacti 1.2.20 and later and keeps its runtime files compatible +with PHP 7.4. Development analysis and the full security suite run on current PHP +versions; CI separately parses every production PHP file with PHP 7.4 so the +advertised installation floor cannot drift unnoticed. + This plugin is to be used to track transactions in the Cacti database, when they were made, by what IP address and by what login account. This can be used to determine the root cause of issues created by users of the Cacti system. diff --git a/audit.php b/audit.php index dcbf213..ff37799 100644 --- a/audit.php +++ b/audit.php @@ -128,12 +128,13 @@ WHERE id = ?', [get_filter_request_var('id')]); - if (!is_array($data)) { + if (!cacti_sizeof($data)) { http_response_code(404); print html_escape(__('Audit event not found.', 'audit')); break; } + /** @var array $data */ audit_record_event('audit.event.viewed', [ 'event_category' => 'audit', @@ -185,7 +186,8 @@ function audit_render_event_details(array $data): string { LIMIT 1', [$data['id']]); - if (is_array($syslog)) { + if (cacti_sizeof($syslog)) { + /** @var array $syslog */ $output .= '
' . __('Remote Syslog Delivery:', 'audit') . ' ' . html_escape($syslog['state']) . ''; $output .= '
' . __('Syslog Attempts:', 'audit') . ' ' . (int) $syslog['attempts'] . ''; @@ -251,7 +253,10 @@ function audit_render_event_details(array $data): string { return $output . ''; } -function audit_render_value(mixed $value): string { +/** + * @param mixed $value + */ +function audit_render_value($value): string { if (is_array($value) || is_object($value)) { return '
' . html_escape(json_encode($value, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE)) . '
'; } @@ -266,22 +271,22 @@ function audit_render_value(mixed $value): string { } function audit_purge(): void { - $protected = db_fetch_cell("SELECT COUNT(*) + $protected = db_fetch_cell_prepared("SELECT COUNT(*) FROM audit_log WHERE EXISTS ( SELECT 1 FROM audit_syslog_delivery WHERE audit_syslog_delivery.audit_id = audit_log.id AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter') - )"); + )", []); - db_execute("DELETE FROM audit_log + db_execute_prepared("DELETE FROM audit_log WHERE NOT EXISTS ( SELECT 1 FROM audit_syslog_delivery WHERE audit_syslog_delivery.audit_id = audit_log.id AND audit_syslog_delivery.state IN ('pending', 'retry', 'dead_letter') - )"); + )", []); $purged = db_affected_rows(); audit_record_event('audit.log.purged', [ @@ -348,6 +353,7 @@ function audit_export_rows(): void { ]); if (cacti_sizeof($events)) { + /** @var array> $events */ header('Content-Disposition: attachment; filename=audit_export.csv'); header('Content-Type: text/csv; charset=UTF-8'); header('X-Content-Type-Options: nosniff'); @@ -357,44 +363,42 @@ function audit_export_rows(): void { 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'] - ]), ',', '"', ''); + 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'] + ]), ',', '"', ''); } fclose($output); @@ -677,31 +681,31 @@ function audit_log(): void { $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); - form_selectable_ecell($e['page'], $e['id']); - form_selectable_ecell($e['user_agent'], $e['id']); - form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); - form_selectable_ecell($e['request_status'], $e['id']); - form_selectable_ecell($e['external_status'], $e['id']); - form_selectable_cell(__('N/A', 'audit'), $e['id']); - form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); - form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); - form_end_row(); - } else { - form_alternate_row('line' . $e['id'], false); - form_selectable_cell(filter_value($e['page'], get_request_var('filter')), $e['id']); - form_selectable_ecell($e['username'], $e['id']); - form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); - form_selectable_ecell($e['request_status'], $e['id']); - form_selectable_ecell($e['external_status'], $e['id']); - 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(); - } + /** @var array> $events */ + + foreach ($events as $e) { + if ($e['action'] == 'cli') { + form_alternate_row('line' . $e['id'], false); + form_selectable_ecell($e['page'], $e['id']); + form_selectable_ecell($e['user_agent'], $e['id']); + form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); + form_selectable_ecell($e['request_status'], $e['id']); + form_selectable_ecell($e['external_status'], $e['id']); + form_selectable_cell(__('N/A', 'audit'), $e['id']); + form_selectable_ecell($e['ip_address'], $e['id'], '', 'right'); + form_selectable_ecell($e['event_time'], $e['id'], '', 'right'); + form_end_row(); + } else { + form_alternate_row('line' . $e['id'], false); + form_selectable_cell(filter_value($e['page'], get_request_var('filter')), $e['id']); + form_selectable_ecell($e['username'], $e['id']); + form_selectable_cell('' . html_escape(ucfirst($e['action'])) . '', $e['id']); + form_selectable_ecell($e['request_status'], $e['id']); + form_selectable_ecell($e['external_status'], $e['id']); + 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(); } } } else { diff --git a/audit_functions.php b/audit_functions.php index db65fe9..e2d88cc 100644 --- a/audit_functions.php +++ b/audit_functions.php @@ -6,10 +6,22 @@ function audit_user_is_admin(): bool { return api_plugin_user_realm_auth('audit_manage.php'); } +/** + * @param array>> $objects + * @param array>|false $result + */ +function audit_append_page_objects(array &$objects, $result): void { + if (cacti_sizeof($result)) { + /** @var array> $result */ + $objects[] = $result; + } +} + /** * @param array $selected_items + * @param mixed $drop_action */ -function audit_process_page_data(string $page, mixed $drop_action, array $selected_items): string { +function audit_process_page_data(string $page, $drop_action, array $selected_items): string { $objects = []; if ($drop_action !== false) { @@ -17,27 +29,27 @@ function audit_process_page_data(string $page, mixed $drop_action, array $select case 'host.php': // 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 + audit_append_page_objects($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 (?)', - [$item]); + [$item])); } break; case 'host_templates.php': foreach ($selected_items as $item) { - $objects[] = db_fetch_assoc_prepared('SELECT name + audit_append_page_objects($objects, db_fetch_assoc_prepared('SELECT name FROM host_template WHERE id IN (?)', - [$item]); + [$item])); } break; case 'templates_export.php': foreach ($selected_items as $item) { - $objects[] = db_fetch_assoc_prepared('SELECT name FROM graph_templates + audit_append_page_objects($objects, db_fetch_assoc_prepared('SELECT name FROM graph_templates WHERE id IN (?)', - [$item]); + [$item])); } break; @@ -48,7 +60,8 @@ function audit_process_page_data(string $page, mixed $drop_action, array $select WHERE id IN (?)', [$item]); - if (is_array($result)) { + if (cacti_sizeof($result)) { + /** @var array> $result */ foreach ($result as &$row) { $row['snmp'] = ($row['snmp'] == 1) ? 'UP' : 'Down'; $row['up'] = ($row['up'] == 1) ? 'Yes' : 'No'; @@ -61,72 +74,72 @@ function audit_process_page_data(string $page, mixed $drop_action, array $select break; case 'graph_templates.php': foreach ($selected_items as $item) { - $objects[] = db_fetch_assoc_prepared('SELECT name + audit_append_page_objects($objects, db_fetch_assoc_prepared('SELECT name FROM graph_templates WHERE id IN (?)', - [$item]); + [$item])); } 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 + audit_append_page_objects($objects, db_fetch_assoc_prepared('SELECT id,name_cache AS THOLD_NAME,data_source_name AS Data_Source FROM thold_data WHERE id IN (?)', - [$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 + audit_append_page_objects($objects, db_fetch_assoc_prepared('select name_cache AS Data_Source_Name,active from data_template_data WHERE local_data_id IN (?)', - [$item]); + [$item])); } break; case 'data_templates.php': foreach ($selected_items as $item) { - $objects[] = db_fetch_assoc_prepared('SELECT name + audit_append_page_objects($objects, db_fetch_assoc_prepared('SELECT name FROM data_template WHERE id IN (?)', - [$item]); + [$item])); } break; case 'aggregate_templates.php': foreach ($selected_items as $item) { - $objects[] = db_fetch_assoc_prepared('SELECT name + audit_append_page_objects($objects, db_fetch_assoc_prepared('SELECT name FROM aggregate_graph_template WHERE id IN (?)', - [$item]); + [$item])); } break; case 'thold_templates.php': foreach ($selected_items as $item) { - $objects[] = db_fetch_assoc_prepared('SELECT name + audit_append_page_objects($objects, db_fetch_assoc_prepared('SELECT name FROM thold_template WHERE id IN (?)', - [$item]); + [$item])); } break; case 'user_admin.php': foreach ($selected_items as $item) { - $objects[] = db_fetch_assoc_prepared('SELECT username + audit_append_page_objects($objects, db_fetch_assoc_prepared('SELECT username FROM user_auth WHERE id IN (?)', - [$item]); + [$item])); } break; case 'user_group_admin.php': foreach ($selected_items as $item) { - $objects[] = db_fetch_assoc_prepared('SELECT name + audit_append_page_objects($objects, db_fetch_assoc_prepared('SELECT name FROM user_auth_group WHERE id IN (?)', - [$item]); + [$item])); } break; @@ -136,11 +149,21 @@ function audit_process_page_data(string $page, mixed $drop_action, array $select return audit_json_encode($objects); } -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); +/** + * @param mixed $key + * @return int + */ +function audit_is_sensitive_key($key) { + $matched = preg_match('/(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)/i', (string) $key); + + return $matched === false ? 1 : $matched; } -function audit_redact_sensitive_data(mixed $data): mixed { +/** + * @param mixed $data + * @return mixed + */ +function audit_redact_sensitive_data($data) { if (!is_array($data)) { return $data; } @@ -160,21 +183,31 @@ function audit_redact_sensitive_data(mixed $data): mixed { return $redacted; } -function audit_redact_sensitive_value(mixed $value): mixed { +/** + * @param mixed $value + * @return mixed + */ +function audit_redact_sensitive_value($value) { if (!is_string($value)) { return $value; } - if (preg_match('/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/', $value) || - preg_match('/^(?:Bearer|Basic)\s+[A-Za-z0-9+\/_=.-]+$/i', trim($value)) || - preg_match('/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/', trim($value))) { + $private_key = preg_match('/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/', $value); + $authorization = preg_match('/^(?:Bearer|Basic)\s+[A-Za-z0-9+\/_=.-]+$/i', trim($value)); + $token = preg_match('/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/', trim($value)); + + if ($private_key !== 0 || $authorization !== 0 || $token !== 0) { return '[REDACTED]'; } - return preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $value); + return preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $value) ?? '[REDACTED]'; } -function audit_bound_log_data(mixed $data, int $depth = 0, ?object $state = null): mixed { +/** + * @param mixed $data + * @return mixed + */ +function audit_bound_log_data($data, int $depth = 0, ?object $state = null) { if ($state === null) { $state = (object) ['fields' => 0]; } @@ -224,26 +257,47 @@ function audit_redact_cli_arguments(array $arguments): array { continue; } - if (preg_match('/^(--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)[^=]*)=(.*)$/i', $argument, $matches)) { + $inline_match = preg_match('/^(--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)[^=]*)=(.*)$/i', $argument, $matches); + + if ($inline_match === false) { + $redacted[] = '[REDACTED]'; + $redact_next = strpos($argument, '=') === false; + + continue; + } + + if ($inline_match === 1) { $redacted[] = $matches[1] . '=[REDACTED]'; continue; } - if (preg_match('/^--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)/i', $argument)) { + $key_match = preg_match('/^--?[^=]*(?:pass(?:word)?|phrase|token|secret|api[_-]?key|private[_-]?key|community|credential|authorization|authentication)/i', $argument); + + if ($key_match === false) { + $redacted[] = '[REDACTED]'; + $redact_next = true; + + continue; + } + + if ($key_match === 1) { $redacted[] = $argument; $redact_next = true; continue; } - $redacted[] = preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $argument) ?? $argument; + $redacted[] = preg_replace('#^([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s]+@#i', '$1:[REDACTED]@', $argument) ?? '[REDACTED]'; } return $redacted; } -function audit_json_encode(mixed $data, int $options = 0): string { +/** + * @param mixed $data + */ +function audit_json_encode($data, int $options = 0): string { $json = json_encode(audit_bound_log_data($data), JSON_INVALID_UTF8_SUBSTITUTE | $options, 16); if ($json === false) { @@ -255,7 +309,11 @@ function audit_json_encode(mixed $data, int $options = 0): string { return $json; } -function audit_json_decode(mixed $json, ?string &$error = null): mixed { +/** + * @param mixed $json + * @return mixed + */ +function audit_json_decode($json, ?string &$error = null) { $error = null; try { @@ -320,7 +378,11 @@ function audit_event_integrity_hash(array $event): string { return hash('sha256', audit_json_encode($material, JSON_UNESCAPED_SLASHES)); } -function audit_event_type_for_request(mixed $page, mixed $action): string { +/** + * @param mixed $page + * @param mixed $action + */ +function audit_event_type_for_request($page, $action): string { $page_name = preg_replace('/\.php$/', '', (string) $page); $page_name = preg_replace('/[^a-z0-9_]+/i', '_', $page_name ?? ''); $verb = preg_replace('/[^a-z0-9_]+/i', '_', strtolower((string) $action)); @@ -392,17 +454,25 @@ function audit_external_log_format(array $data, string $format = 'json'): string return audit_json_encode($data, JSON_UNESCAPED_SLASHES) . "\n"; } -function audit_csv_safe_cell(mixed $value): string { +/** + * @param mixed $value + */ +function audit_csv_safe_cell($value): string { $value = (string) $value; - if (preg_match('/^[=+\-@]/', ltrim($value))) { + $formula = preg_match('/^[=+\-@]/', ltrim($value)); + + if ($formula !== 0) { return "'" . $value; } return $value; } -function audit_retention_cutoff(mixed $retention, ?DateTimeImmutable $now = null): DateTimeImmutable { +/** + * @param mixed $retention + */ +function audit_retention_cutoff($retention, ?DateTimeImmutable $now = null): DateTimeImmutable { $now = $now instanceof DateTimeImmutable ? $now->setTimezone(new DateTimeZone('UTC')) : new DateTimeImmutable('now', new DateTimeZone('UTC')); @@ -445,7 +515,7 @@ function audit_deliver_external_event(int $id): void { $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); - if (!is_array($event) || $event === [] || ($event['request_status'] ?? '') === 'started') { + if (!cacti_sizeof($event) || !isset($event['request_status']) || $event['request_status'] === 'started') { return; } @@ -484,7 +554,8 @@ function audit_retry_external_logs(): void { ORDER BY id LIMIT 100"); - if (is_array($events)) { + if (cacti_sizeof($events)) { + /** @var array> $events */ foreach ($events as $event) { $message = audit_external_log_format(audit_external_event_data($event), $format); $delivery = audit_append_external_log($path, $message); @@ -561,8 +632,9 @@ function audit_operation_verifier_for_request(string $page, array $post): ?array /** * @return array + * @param mixed $verifier */ -function audit_verify_operation(mixed $verifier): array { +function audit_verify_operation($verifier): array { if (!is_array($verifier) || empty($verifier['type'])) { return ['outcome' => 'unknown', 'reason' => null]; } @@ -652,7 +724,8 @@ function audit_finalize_request(int $id, ?float $started_at = null, ?array $veri $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); - if (is_array($event)) { + if (cacti_sizeof($event)) { + /** @var array $event */ db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', [audit_event_integrity_hash($event), $id]); } @@ -703,7 +776,8 @@ function audit_record_event(string $event_type, array $options = []): int { $id = db_fetch_insert_id(); $event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', [$id]); - if (is_array($event)) { + if (cacti_sizeof($event)) { + /** @var array $event */ db_execute_prepared('UPDATE audit_log SET integrity_hash = ? WHERE id = ?', [audit_event_integrity_hash($event), $id]); } diff --git a/audit_syslog.php b/audit_syslog.php index 10ff4f0..f66e3fa 100644 --- a/audit_syslog.php +++ b/audit_syslog.php @@ -19,7 +19,11 @@ function audit_syslog_enabled(): bool { return read_config_option('audit_syslog_enabled') == 'on'; } -function audit_syslog_read_setting(string $name, mixed $default): mixed { +/** + * @param mixed $default + * @return mixed + */ +function audit_syslog_read_setting(string $name, $default) { $value = read_config_option($name); return $value === '' || $value === null ? $default : $value; @@ -27,8 +31,9 @@ function audit_syslog_read_setting(string $name, mixed $default): mixed { /** * @param array $errors + * @param mixed $value */ -function audit_syslog_bounded_integer(mixed $value, int $default, int $minimum, int $maximum, array &$errors, string $name): int { +function audit_syslog_bounded_integer($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'; @@ -78,8 +83,13 @@ function audit_syslog_valid_receiver(string $receiver): bool { } function audit_syslog_valid_header_value(string $value, int $maximum): bool { - return $value !== '' && strlen($value) <= $maximum && - !preg_match('/[^\\x21-\\x7e]|[\\[\\]="]/', $value); + if ($value === '' || strlen($value) > $maximum) { + return false; + } + + $invalid = preg_match('/[^\\x21-\\x7e]|[\\[\\]="]/', $value); + + return $invalid === 0; } /** @@ -268,7 +278,10 @@ function audit_syslog_facilities(): array { ]; } -function audit_syslog_severity_code(mixed $severity): int { +/** + * @param mixed $severity + */ +function audit_syslog_severity_code($severity): int { $map = [ 'emergency' => 0, 'emerg' => 0, 'alert' => 1, 'critical' => 2, 'crit' => 2, 'error' => 3, 'err' => 3, 'warning' => 4, @@ -279,20 +292,29 @@ function audit_syslog_severity_code(mixed $severity): int { return isset($map[$severity]) ? $map[$severity] : 6; } -function audit_syslog_header_token(mixed $value, int $maximum, string $fallback): string { +/** + * @param mixed $value + */ +function audit_syslog_header_token($value, int $maximum, string $fallback): string { $value = preg_replace('/[^\\x21-\\x3c\\x3e-\\x5a\\x5e-\\x7e]/', '_', (string) $value); $value = substr($value ?? '', 0, $maximum); return $value === '' ? $fallback : $value; } -function audit_syslog_structured_value(mixed $value): string { +/** + * @param mixed $value + */ +function audit_syslog_structured_value($value): string { $value = preg_replace('/[\\x00-\\x1f\\x7f]/', ' ', (string) $value); return str_replace(['\\', '"', ']'], ['\\\\', '\\"', '\\]'], $value ?? ''); } -function audit_syslog_timestamp(mixed $value): string { +/** + * @param mixed $value + */ +function audit_syslog_timestamp($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)) { @@ -315,11 +337,17 @@ function audit_syslog_normalized_data(array $event, array $config): array { return $data; } -function audit_syslog_cef_escape_header(mixed $value): string { +/** + * @param mixed $value + */ +function audit_syslog_cef_escape_header($value): string { return str_replace(['\\', '|', "\r", "\n"], ['\\\\', '\\|', ' ', ' '], (string) $value); } -function audit_syslog_cef_escape_extension(mixed $value): string { +/** + * @param mixed $value + */ +function audit_syslog_cef_escape_extension($value): string { return str_replace( ['\\', '=', "\r", "\n"], ['\\\\', '\\=', '\\r', '\\n'], @@ -327,7 +355,10 @@ function audit_syslog_cef_escape_extension(mixed $value): string { ); } -function audit_syslog_cef_severity(mixed $severity): int { +/** + * @param mixed $severity + */ +function audit_syslog_cef_severity($severity): int { $map = [ 'emergency' => 10, 'emerg' => 10, 'alert' => 10, 'critical' => 9, 'crit' => 9, 'error' => 8, 'err' => 8, @@ -339,7 +370,10 @@ function audit_syslog_cef_severity(mixed $severity): int { return isset($map[$severity]) ? $map[$severity] : 3; } -function audit_syslog_cef_event_field(mixed $value): string { +/** + * @param mixed $value + */ +function audit_syslog_cef_event_field($value): string { if (is_string($value) && $value !== '') { $decoded = audit_json_decode($value, $error); @@ -392,7 +426,7 @@ function audit_syslog_cef_payload(array $event, array $config): string { 'cs2Label' => 'Target', 'cs2' => trim(($event['target_type'] ?? '') . ':' . ($event['target_id'] ?? ''), ':'), 'cs3Label' => 'Node ID', - 'cs3' => $config['node_id'], + 'cs3' => audit_syslog_cef_event_field($config['node_id']), 'cn1Label' => 'Poller ID', 'cn1' => $config['poller_id'], 'cs4Label' => 'Submitted Data', @@ -526,7 +560,10 @@ function audit_syslog_socket_target(array $config): string { return $scheme . '://' . $receiver . ':' . $config['port']; } -function audit_syslog_stream_operation(callable $operation, string &$warning = ''): mixed { +/** + * @return mixed + */ +function audit_syslog_stream_operation(callable $operation, string &$warning = '') { $warning = ''; $handler = function ($severity, $message) use (&$warning) { $warning = audit_syslog_bounded_error($message); @@ -614,13 +651,20 @@ function audit_syslog_open_socket(array $config): array { return ['socket' => $socket, 'error_code' => '', 'error' => '']; } -function audit_syslog_bounded_error(mixed $error): string { +/** + * @param mixed $error + */ +function audit_syslog_bounded_error($error): string { $error = preg_replace('/[\\x00-\\x1f\\x7f]+/', ' ', (string) $error); return substr(trim($error ?? ''), 0, 1024); } -function audit_syslog_fwrite(mixed $socket, string $message, string &$warning = ''): int|false { +/** + * @param mixed $socket + * @return int|false + */ +function audit_syslog_fwrite($socket, string $message, string &$warning = '') { return audit_syslog_stream_operation(function () use ($socket, $message) { return fwrite($socket, $message); }, $warning); @@ -628,8 +672,9 @@ function audit_syslog_fwrite(mixed $socket, string $message, string &$warning = /** * @return array + * @param mixed $socket */ -function audit_syslog_write(mixed $socket, string $message, string $transport): array { +function audit_syslog_write($socket, string $message, string $transport): array { if (!is_resource($socket)) { return ['status' => 'failed', 'error_code' => 'socket_unavailable', 'error' => 'Syslog socket is unavailable.']; } @@ -672,9 +717,10 @@ function audit_syslog_write(mixed $socket, string $message, string $transport): /** * @param array $event * @param array $config + * @param null|mixed $socket * @return array */ -function audit_syslog_send_event(array $event, array $config, mixed &$socket = null): array { +function audit_syslog_send_event(array $event, array $config, &$socket = null): array { $formatted = audit_syslog_record($event, $config); if ($formatted['status'] !== 'ready') { @@ -718,7 +764,12 @@ function audit_enqueue_syslog_event(int $audit_id): void { WHERE id = ?', [$audit_id]); - if (!is_array($event) || $event['request_status'] === 'started' || $event['event_uuid'] === '') { + if (!cacti_sizeof($event)) { + return; + } + /** @var array $event */ + + if ($event['request_status'] === 'started' || $event['event_uuid'] === '') { return; } @@ -757,8 +808,9 @@ function audit_syslog_delivery_config(array $config, array $delivery): array { /** * @param array $config + * @param mixed $attempt */ -function audit_syslog_retry_delay(mixed $attempt, array $config): int { +function audit_syslog_retry_delay($attempt, array $config): int { $exponent = min(max(0, (int) $attempt - 1), 30); $delay = $config['retry_base'] * pow(2, $exponent); @@ -836,7 +888,8 @@ function audit_process_syslog_queue(): void { []); $socket = null; - if (is_array($deliveries)) { + if (cacti_sizeof($deliveries)) { + /** @var array> $deliveries */ foreach ($deliveries as $delivery) { $delivery_config = audit_syslog_delivery_config($config, $delivery); $result = audit_syslog_send_event($delivery, $delivery_config, $socket); diff --git a/phpstan/index.php b/phpstan/index.php new file mode 100644 index 0000000..4e67c6b --- /dev/null +++ b/phpstan/index.php @@ -0,0 +1,3 @@ +> $realm_ids */ foreach ($realm_ids as $realm) { db_execute_prepared('REPLACE INTO user_auth_realm (user_id, realm_id) @@ -81,7 +82,8 @@ function audit_remove_deprecated_realms(): void { AND file = ?', ['audit', 'audit_purge.php']); - if (is_array($realms)) { + if (cacti_sizeof($realms)) { + /** @var array> $realms */ foreach ($realms as $realm) { $realm_id = $realm['id'] + 100; @@ -368,7 +370,10 @@ function audit_setup_syslog_table(): void { AFTER node_id'); } -function audit_upgrade_event_schema(mixed $rcnn_id = false): void { +/** + * @param mixed $rcnn_id + */ +function audit_upgrade_event_schema($rcnn_id = false): void { $remote = $rcnn_id !== false; $args = $remote ? [true, $rcnn_id] : []; $columns = [ @@ -421,7 +426,7 @@ function audit_upgrade_event_schema(mixed $rcnn_id = false): void { */ function plugin_audit_version(): array { global $config; - $info = parse_ini_file($config['base_path'] . '/plugins/audit/INFO', true); + $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 : []; diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php new file mode 100644 index 0000000..11ff159 --- /dev/null +++ b/tests/Security/Php74CompatibilityTest.php @@ -0,0 +1,85 @@ +not->toBeFalse() + ->and($hasPhp8OnlyType((string) $contents))->toBeFalse() + ->and($contents)->not->toContain('str_contains(') + ->and($contents)->not->toContain('str_starts_with(') + ->and($contents)->not->toContain('str_ends_with(') + ->and($contents)->not->toContain('?->'); + } + }); + + it('distinguishes PHPDoc and regex text from native declarations', function () use ($hasPhp8OnlyType) { + expect($hasPhp8OnlyType("/** @param mixed \$value */\nfunction safe(\$value) { return '/a|b/'; }"))->toBeFalse() + ->and($hasPhp8OnlyType('function nativeMixed(mixed $value) {}'))->toBeTrue() + ->and($hasPhp8OnlyType('function nativeUnion($value): int|false {}'))->toBeTrue() + ->and($hasPhp8OnlyType("function url() { return 'https://example.test'; } function nativeMixed(mixed \$value) {}"))->toBeTrue(); + }); + + it('keeps the compatibility floor explicit in plugin metadata', function () { + $info = parse_ini_file(__DIR__ . '/../../INFO', true); + + expect($info['info']['compat'] ?? null)->toBe('1.2.20'); + }); + + it('keeps developer-only PHP excluded from the runtime lint sweep', function () { + $workflow = file_get_contents(__DIR__ . '/../../.github/workflows/code-quality.yml'); + $config = file_get_contents(__DIR__ . '/../../.phpstan.neon'); + + expect($workflow)->not->toBeFalse() + ->and($workflow)->toContain("-path './phpstan' -prune") + ->and($workflow)->toContain("-path './tests' -prune") + ->and($config)->not->toBeFalse() + ->and($config)->toContain('phpstan/stubs/cacti.stub'); + }); +}); diff --git a/tests/Security/Php81SyntaxTest.php b/tests/Security/Php81SyntaxTest.php deleted file mode 100644 index f0daa1f..0000000 --- a/tests/Security/Php81SyntaxTest.php +++ /dev/null @@ -1,43 +0,0 @@ -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 9a0c8cc..22bbcf8 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -13,6 +13,19 @@ */ describe('prepared statement consistency in audit', function () { + it('keeps the audit purge delete on the prepared helper', function () { + $contents = file_get_contents(__DIR__ . '/../../audit.php'); + + expect($contents)->not->toBeFalse(); + + $matched = preg_match('/function audit_purge\(\): void \{(?.*?)\n\}/s', (string) $contents, $matches); + $body = $matched === 1 && isset($matches['body']) ? $matches['body'] : ''; + + expect($matched)->toBe(1) + ->and($body)->toContain('db_execute_prepared("DELETE FROM audit_log') + ->and($body)->not->toMatch('/\bdb_execute\s*\(/'); + }); + it('documents database helper usage in all plugin files', function () { $targetFiles = [ 'audit.php', diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php index e3799d5..2fc9d44 100644 --- a/tests/Security/SetupStructureTest.php +++ b/tests/Security/SetupStructureTest.php @@ -9,8 +9,11 @@ // Verify setup.php defines required plugin hooks and info function. +require_once __DIR__ . '/../bootstrap.php'; + describe('audit setup.php structure', function () { $source = file_get_contents(realpath(__DIR__ . '/../../setup.php')); + require_once __DIR__ . '/../../setup.php'; it('defines plugin_audit_install function', function () use ($source) { expect($source)->toContain('function plugin_audit_install'); @@ -32,6 +35,31 @@ 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 : []'); + + $GLOBALS['config']['base_path'] = '/definitely-missing-audit-test-path'; + set_error_handler(static function (): bool { + return true; + }); + + try { + expect(plugin_audit_version())->toBe([]); + } finally { + restore_error_handler(); + } + }); + + it('fails closed when realm queries fail', function () { + $GLOBALS['__test_db_calls'] = []; + $GLOBALS['__test_config_options']['admin_user'] = '1'; + $GLOBALS['__test_db_fetch_assoc_prepared_result'] = false; + + audit_setup_realms(false); + audit_remove_deprecated_realms(); + + expect($GLOBALS['__test_db_calls'])->toBe([]); + + $GLOBALS['__test_db_fetch_assoc_prepared_result'] = []; + $GLOBALS['__test_config_options'] = []; }); it('INFO file defines name and version keys', function () { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e268ffd..41c39a4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,7 +12,9 @@ * can be loaded in isolation without the full Cacti application. */ -$GLOBALS['__test_db_calls'] = []; +$GLOBALS['__test_db_calls'] = []; +$GLOBALS['__test_db_fetch_assoc_prepared_result'] = []; +$GLOBALS['__test_config_options'] = []; if (!function_exists('db_execute')) { function db_execute($sql) { @@ -38,7 +40,7 @@ function db_fetch_assoc($sql) { if (!function_exists('db_fetch_assoc_prepared')) { function db_fetch_assoc_prepared($sql, $params = []) { - return []; + return $GLOBALS['__test_db_fetch_assoc_prepared_result']; } } @@ -102,9 +104,21 @@ function api_plugin_db_table_create($plugin, $table, $data) { } } +if (!function_exists('api_plugin_register_realm')) { + /** + * @param string $plugin + * @param string $file + * @param string $display + * @param int $grant + * @return void + */ + function api_plugin_register_realm($plugin, $file, $display, $grant = 0) { + } +} + if (!function_exists('read_config_option')) { function read_config_option($name, $force = false) { - return ''; + return $GLOBALS['__test_config_options'][$name] ?? ''; } } diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php index 80ba0c0..de4b740 100644 --- a/tests/controller_security_test.php +++ b/tests/controller_security_test.php @@ -90,6 +90,16 @@ exit(1); } +$missing_event_guard = strpos((string) $controller, 'if (!cacti_sizeof($data))'); +$missing_event_404 = strpos((string) $controller, 'http_response_code(404)', (int) $missing_event_guard); +$view_event_write = strpos((string) $controller, "audit_record_event('audit.event.viewed'"); + +if ($missing_event_guard === false || $missing_event_404 === false || $view_event_write === false || + $missing_event_guard > $missing_event_404 || $missing_event_404 > $view_event_write) { + fwrite(STDERR, 'Missing audit events must return before recording a view event.' . PHP_EOL); + exit(1); +} + if (strpos($functions, 'audit_enforce_syslog_settings_request()') === false || strpos($functions, "'audit.syslog.configuration.denied'") === false) { fwrite(STDERR, 'Remote Syslog settings must enforce Audit Log Admin on save.' . PHP_EOL); diff --git a/tests/index.php b/tests/index.php new file mode 100644 index 0000000..4e67c6b --- /dev/null +++ b/tests/index.php @@ -0,0 +1,3 @@ +|false + */ +function db_fetch_assoc($sql) { + global $audit_test_external_events; + + return $audit_test_external_events; +} + +/** + * @param mixed $value + * @return int + */ +function cacti_sizeof($value) { + return is_array($value) ? count($value) : 0; +} + +/** + * @param string $sql + * @param array $params + * @return array|false + */ +function db_fetch_row_prepared($sql, $params = []) { global $audit_test_external_event; return $audit_test_external_event; } -function db_execute_prepared(string $sql, array $params = []): bool { +/** + * @param string $sql + * @param array $params + * @return bool + */ +function db_execute_prepared($sql, $params = []) { global $audit_test_external_updates; $audit_test_external_updates[] = ['sql' => $sql, 'params' => $params]; @@ -59,7 +88,11 @@ function db_execute_prepared(string $sql, array $params = []): bool { return true; } -function read_config_option(string $name): mixed { +/** + * @param string $name + * @return mixed + */ +function read_config_option($name) { global $audit_test_config_options; return $audit_test_config_options[$name] ?? ''; @@ -153,11 +186,19 @@ function audit_test_assert_same($expected, $actual, $message) { $audit_test_realm_query_failure = false; $audit_test_object_query_failure = true; -audit_test_assert_same( - [], - json_decode(audit_process_page_data('automation_devices.php', '1', ['42']), true), - 'Failed automation-device queries must not add false entries to object data.' -); +$object_pages = [ + 'host.php', 'host_templates.php', 'templates_export.php', 'automation_devices.php', + 'graph_templates.php', 'thold.php', 'data_sources.php', 'data_templates.php', + 'aggregate_templates.php', 'thold_templates.php', 'user_admin.php', 'user_group_admin.php' +]; + +foreach ($object_pages as $object_page) { + audit_test_assert_same( + [], + json_decode(audit_process_page_data($object_page, '1', ['42']), true), + 'Failed object queries must not add false entries for ' . $object_page . '.' + ); +} $audit_test_object_query_failure = false; $request = [ @@ -198,6 +239,28 @@ function audit_test_assert_same($expected, $actual, $message) { 'Credentials embedded in a URI must be redacted.' ); +$original_backtrack_limit = ini_get('pcre.backtrack_limit'); +ini_set('pcre.backtrack_limit', '0'); +$failed_redaction = audit_redact_cli_arguments(['https://user:must-not-leak@example.com/path']); +$failed_inline_cli = audit_redact_cli_arguments(['--password=must-not-leak']); +$failed_separated_cli = audit_redact_cli_arguments(['--api-token', 'must-not-leak']); +$failed_post_redaction = audit_redact_sensitive_data([ + 'password' => 'must-not-leak', + 'nested' => ['api_token' => 'must-not-leak'], +]); +$failed_value_redaction = audit_redact_sensitive_value('https://user:must-not-leak@example.com/path'); +$failed_csv_redaction = audit_csv_safe_cell('=must-not-execute'); +ini_set('pcre.backtrack_limit', (string) $original_backtrack_limit); +audit_test_assert_same('[REDACTED]', $failed_redaction[0], 'URI redaction failures must fail closed.'); +audit_test_assert_same('[REDACTED]', $failed_inline_cli[0], 'Inline CLI key-matching failures must fail closed.'); +audit_test_assert_same('[REDACTED]', $failed_separated_cli[0], 'Separated CLI key-matching failures must redact the option.'); +audit_test_assert_same('[REDACTED]', $failed_separated_cli[1], 'Separated CLI key-matching failures must redact the value.'); +audit_test_assert_same('[REDACTED]', $failed_post_redaction['password'], 'Sensitive key matching failures must fail closed.'); +audit_test_assert_same('[REDACTED]', $failed_post_redaction['nested']['api_token'], 'Nested sensitive key matching failures must fail closed.'); +audit_test_assert_same('[REDACTED]', $failed_value_redaction, 'Sensitive value matching failures must fail closed.'); +audit_test_assert_same('[REDACTED]', audit_syslog_cef_event_field($failed_value_redaction), 'Failed redaction must remain safe through CEF formatting.'); +audit_test_assert_same("'=must-not-execute", $failed_csv_redaction, 'CSV formula matching failures must fail closed.'); + audit_test_assert_same("'=1+1", audit_csv_safe_cell('=1+1'), 'Spreadsheet formulas must be neutralized.'); $deep = []; @@ -335,6 +398,16 @@ function audit_test_assert_same($expected, $actual, $message) { audit_test_assert_same('', file_get_contents($temporary_log), 'Empty audit events must not create external records.'); audit_test_assert_same([], $audit_test_external_updates, 'Empty audit events must not update delivery status.'); +$audit_test_external_event = ['id' => 999]; +audit_deliver_external_event(999); +audit_test_assert_same('', file_get_contents($temporary_log), 'Events without a request status must not create external records.'); +audit_test_assert_same([], $audit_test_external_updates, 'Events without a request status must not update delivery status.'); + +$audit_test_external_events = false; +audit_retry_external_logs(); +audit_test_assert_same('', file_get_contents($temporary_log), 'Failed retry queries must not append external records.'); +audit_test_assert_same([], $audit_test_external_updates, 'Failed retry queries must not update delivery status.'); + unlink($temporary_log); print "Security helper tests passed.\n"; diff --git a/tests/syslog_functions_test.php b/tests/syslog_functions_test.php index 5dae55e..86e5740 100644 --- a/tests/syslog_functions_test.php +++ b/tests/syslog_functions_test.php @@ -38,6 +38,17 @@ function audit_syslog_test_config($overrides = []) { return audit_syslog_config(array_merge($values, $overrides)); } +$original_backtrack_limit = ini_get('pcre.backtrack_limit'); +ini_set('pcre.backtrack_limit', '0'); +$failed_header_config = audit_syslog_test_config([ + 'format' => 'cef', + 'node_id' => "node\x01identifier" +]); +$failed_header_payload = audit_syslog_cef_payload(audit_syslog_test_event(), $failed_header_config); +ini_set('pcre.backtrack_limit', (string) $original_backtrack_limit); +audit_syslog_test_assert(!$failed_header_config['valid'], 'Header matching failures must invalidate the Syslog configuration.'); +audit_syslog_test_assert(strpos($failed_header_payload, "\x01") === false, 'CEF payloads must not retain control bytes from a failed header validation.'); + function audit_syslog_test_event() { return [ 'id' => 42, diff --git a/tests/syslog_queue_test.php b/tests/syslog_queue_test.php index b88913c..bcfa588 100644 --- a/tests/syslog_queue_test.php +++ b/tests/syslog_queue_test.php @@ -17,12 +17,19 @@ 'audit_syslog_batch_size' => '10', 'audit_syslog_pending_age_warning' => '900', 'audit_syslog_dead_letter_warning' => '1', + 'audit_syslog_health_state' => 'healthy', 'audit_syslog_tls_ca_file' => '', 'audit_syslog_tls_client_cert' => '', 'audit_syslog_tls_client_key' => '' ]; $audit_queue_calls = []; $audit_queue_affected_rows = 0; +$audit_queue_event = [ + 'id' => 42, + 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', + 'request_status' => 'completed' +]; +$audit_queue_deliveries = false; function read_config_option($name) { global $audit_queue_settings; @@ -35,11 +42,36 @@ function db_table_exists($table) { } function db_fetch_row_prepared($sql, $params = []) { - return [ - 'id' => (int) $params[0], - 'event_uuid' => '32e0a97d-d9e8-4abc-8f41-2bbbc50793ca', - 'request_status' => 'completed' - ]; + global $audit_queue_event; + + return $audit_queue_event; +} + +/** + * @param string $sql + * @param array $params + * @return array|false + */ +function db_fetch_assoc_prepared($sql, $params = []) { + global $audit_queue_deliveries; + + return $audit_queue_deliveries; +} + +/** + * @param string $sql + * @return array + */ +function db_fetch_row($sql) { + return []; +} + +/** + * @param string $sql + * @return string + */ +function db_fetch_cell($sql) { + return ''; } function db_execute_prepared($sql, $params = []) { @@ -64,6 +96,24 @@ function db_affected_rows() { return $audit_queue_affected_rows; } +/** + * @param mixed $message + * @param mixed $also_print + * @param mixed $log_type + * @param mixed $level + * @return void + */ +function cacti_log($message, $also_print = false, $log_type = '', $level = 0) { +} + +/** + * @param mixed $name + * @param mixed $value + * @return void + */ +function set_config_option($name, $value) { +} + function cacti_sizeof($value) { return is_array($value) ? count($value) : 0; } @@ -89,6 +139,12 @@ 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.'); +$audit_queue_calls = []; +$audit_queue_event = []; +audit_enqueue_syslog_event(404); +audit_queue_assert($audit_queue_calls === [], + 'A missing audit row must not enqueue a delivery with a null audit ID.'); + $config = audit_syslog_config(); $retry_identity = audit_syslog_delivery_config($config, [ 'delivery_node_id' => 'original-node', @@ -118,6 +174,11 @@ 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.'); +$calls_before_failed_fetch = $audit_queue_calls; +audit_process_syslog_queue(); +audit_queue_assert($audit_queue_calls === $calls_before_failed_fetch, + 'A failed Syslog queue fetch must not attempt a delivery update.'); + $audit_queue_calls = []; $delivery['attempts'] = 4; audit_syslog_update_delivery($delivery, $failure, $config);