16230 Commits
Author SHA1 Message Date
TheWitness ac2445fab5 Exclude 'tests' directories from resource replication (#8118)
* poller: exclude 'tests' directories from resource replication

should_ignore_from_replication() now skips any path with a 'tests' segment, covering Cacti's own tests/ and every plugin's tests/ for both the cache-in (md5sum_path/update_db_from_path) and cache-out (resource_cache_out) directions. update_resource_cache()'s plugin scan also adds 'tests' to its directory exclusions so a plugin's tests/ tree is never cached in the first place. Adds a Pest unit test covering both directions and the plugin-scan exclusion.

* poller: purge stale excluded rows, guard collector dirs, strip install root

Address review feedback on #8118:

- should_ignore_from_replication() now strips CACTI_PATH_BASE from absolute file paths before segment matching, so an installation living under a 'tests' ancestor (e.g. /srv/tests/cacti) no longer rejects ordinary files.

- update_resource_cache() purges rows that are now excluded even when their source file still exists, dropping tests/* rows cached before the exclusion.

- The remote collector skips excluded plugin rows before building directories, so it no longer recreates plugins/foo/tests/ from stale cache rows.

Adds regression tests for the absolute-path handling and both source-level guards.
2026-09-29 23:08:10 -04:00
TheWitness a724239758 fix: Allow api_plugin_db_table_create to do updates (and correct default drift) (#8113)
* fix: allow api_plugin_db_table_create to update, and correct default drift

Mirrors the 1.2.x change so api_plugin_db_table_create() refreshes an
existing plugin table via db_update_table() instead of silently doing
nothing, recording the change in plugin_db_changes as a 'create'.

Also teaches db_update_table() to compare the column default, so a
refresh reconciles a column whose live default has drifted from the
canonical definition (e.g. DEFAULT NULL where the definition specifies
DEFAULT ''). Date/time defaults are left to the type comparison to
avoid churn.

* fix: do not re-record plugin_db_changes when refreshing an existing table

Mirrors the 1.2.x fix: plugin_db_changes has no unique key, so REPLACE INTO
on the update path duplicated the 'create' ownership record on every call and
could mark a pre-existing/shared table as plugin-created (dropped on uninstall
by api_plugin_db_changes_remove()). Update existing tables without inserting
another ownership record.
2026-09-29 21:45:46 -04:00
TheWitnessandTheWitness d4b8cd3814 fix: align Force Uninstall icon (.piforceremove) on the Plugins page (#8110)
* fix: align Force Uninstall icon by adding .piforceremove to icon-padding rule

The orphaned-plugin (-5) control introduced in #8107 renders a .piforceremove anchor, but that class was missing from the plugin-action icon padding rule, so the icon lost its 0 3px padding and misaligned in the Actions column. Add .piforceremove alongside .pierror in every theme stylesheet.

* fix: align Force Uninstall confirm wording to standard 'Otherwise click Cancel.' pattern

develop counterpart of the #8109 dialog copy fix. develop already uses the non-modal displayDialog() with a plain-text message, so only the closing sentence is aligned to the house pattern (with trailing period). cacti.pot msgid updated to match.

* fix: bump midwinter core.css cache-buster hash to match the updated file

The Force Uninstall (.piforceremove) alignment change edited
include/themes/midwinter/css/media/core.css, changing its content md5 to
afd5bc6f4d45884846ab715f1583123c, but main.css still imported it with the old
?ad4ed321... query string. Browsers holding the pre-change stylesheet in cache
would keep the icon misaligned. Update the import hash so the change is picked up.

---------

Co-authored-by: TheWitness <copilot@users.noreply.github.com>
2026-09-29 16:32:46 -05:00
github-actions[bot] 44a14fb5e8 ci: refresh security/SQL-interpolation baselines (#8108)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-29 15:12:58 -04:00
TheWitnessandCopilot 599c76b369 Add Force Uninstall for orphaned plugins whose directory is missing (develop) (#8107)
* Add Force Uninstall for orphaned plugins with a missing directory (#8105)

The Plugin Management page showed a directory-missing plugin (status -5) with only an inert info icon, so its stale plugin_config/hooks/realms/db_changes entries could not be cleaned up from the UI. Make that icon a Force Uninstall action guarded by the existing displayDialog() modal confirmation; on confirm it POSTs action=remove, which calls api_plugin_uninstall(plugin, true) - that skips the unavailable uninstall hook and removes the plugin's hooks, realms, plugin_config row, and the tables/columns it created (via plugin_db_changes).

* Force Uninstall: POST via dialog form to satisfy CSRF action guard; add cacti.pot strings

* Force Uninstall: restrict to truly-orphaned plugins, block if required by others, replicate config to remote pollers

* Refresh sink inventory baseline for Force Uninstall header redirects in plugins.php

* Force Uninstall: post action=remove via dialog form fields (not a URL) and JSON-inject dialog strings to satisfy CsrfGetDeleteTest and the inline-JS hardening guard

---------

Co-authored-by: Copilot <copilot@users.noreply.github.com>
2026-09-29 14:40:56 -04:00
TheWitness 8a73fc6703 tree: Tabler glyphs for jsTree nodes with per-type theme colors and glyph carets (#8101)
* tree: replace jsTree node PNGs with Tabler glyphs, per-type theme colors, and glyph open/close carets

* tree: center carets to 20px node row, remove indent dotted lines, add dots:false to editor source tree

* tree: center jsTree carets with flexbox so alignment is glyph-height independent

* tree: vertically center node glyph with its label text (vertical-align middle)

* tree: preserve jsTree loading throbber and add default folder glyph

Scope the .jstree-ocl reset and caret glyph content to :not(.jstree-loading) so AJAX expansion keeps its loading indicator, and register a 'ti ti-folder' glyph (with per-theme color) for the untyped 'default' branch type so branch nodes are not left with a blank icon.
2026-09-29 10:42:29 -04:00
TheWitnessandCopilot 54f1dbabdd security: validate host_id before the graph_view filter WHERE clauses (GHSA-r7hc-hrpv-52gf) (#8100)
* security: validate host_id before the graph_view filter WHERE clauses (GHSA-r7hc-hrpv-52gf)

* security: cover graph_view host_id WHERE sinks with regression tests and add PR ref (GHSA-r7hc-hrpv-52gf)

---------

Co-authored-by: Copilot <copilot@example.com>
2026-09-29 10:25:12 -04:00
TheWitness e66a324d1a security: validate local_graph_id before the aggregate graph item sequence query (GHSA-6mjm-5vwr-3fr2) (#8099)
* security: validate local_graph_id before the aggregate graph item sequence query (GHSA-6mjm-5vwr-3fr2)

* security: bind local_graph_id via array filter and add regression test (GHSA-6mjm-5vwr-3fr2)
2026-09-29 00:08:17 -04:00
TheWitness 3a4116b83a links: use ti-eye view glyph with theme colors and narrow action/order columns (#8097) 2026-09-28 21:40:07 -04:00
TheWitness 8a19e855e3 locales: drop source line numbers from translation template (#8095)
build_gettext.sh now runs xgettext with --add-location=file (locations
record only the file name, not file:line) and --from-code=UTF-8. The
line-numbered locations churn on almost every code change and repeatedly
break the translation-template CI check on unrelated PRs; filename-only
locations stay stable when lines move. --from-code=UTF-8 makes xgettext
parse the UTF-8 sources deterministically (and is required on 1.2.x where
a non-ASCII comment otherwise aborts it). Regenerated cacti.pot; only
location comments and UTF-8 encoding changed (no msgids added/removed).
2026-09-28 20:46:06 -04:00
TheWitness e29581b68a Make package/diff viewer colors theme-aware (#8093)
* Make package/diff viewer colors theme-aware

The jfcherng/php-diff Inline renderer output is styled by each theme's
Diff.css, but every theme shipped an identical copy that hard-coded a
light palette (color: black, background: #fff, opaque #fbe1e1/#e1fbe1
add/remove tints). Inside the diff dialog this painted a light box with
dark text over dark themes (dark, sunrise, paper-plane) and over
midwinter's dark color-mode, so the modal did not match the theme.

Derive the base surface from the themed dialog instead of hard-coding it
(color: inherit, background: transparent, neutral translucent borders and
gutters) and express the add/remove/replace highlights as translucent
rgba overlays that read correctly on both light and dark surfaces. Light
themes render essentially as before; dark themes now inherit their dark
surface and light text. Applied to every theme so the shared stylesheet
stays identical.

* Band diff code columns on dark themes

On the dark themes the unchanged (context) code column and the
line-number gutter read too similarly, making the actual diff on the
right hard to pick out. Give the context old/new code cells a subtle
dark band (rgba(0,0,0,.25)) so the code reads as a panel distinct from
the lighter line-number gutter. Applied to dark, sunrise and paper-plane;
for midwinter the band is scoped to its dark color-mode only. Light
themes are unchanged.
2026-09-28 15:21:51 -04:00
TheWitness 5fd5d674cf docs(auth): clarify LDAP OTP/MFA single-bind and service-account guidance (#8090)
* docs(auth): clarify LDAP OTP/MFA single-bind and service-account guidance

Adds guidance to the LDAP Mode and Server(s) provider descriptions that
Cacti binds the user's password only once (during final authentication),
so One-Time Password (OTP/MFA) logins should use Specific Searching with a
service account (or Anonymous Searching where permitted) and a single
server, since multi-server failover would re-attempt the bind with an
already-consumed one-time password.

* docs(auth): recommend resilient DNS load balancer for LDAP OTP; discourage space-delimited multi-server list; clarify No Searching
2026-09-28 15:21:40 -04:00
TheWitness 46908e350b fix(ci): pin htmx.org back to 2.0.10 to match vendored asset and SRI (#8091)
* fix(ci): pin htmx.org back to 2.0.10 to match vendored asset and SRI

The npm group bump #8085 raised htmx.org to 4.0.0, but the vendored
include/js/htmx.js, the HTMX_2_0_10_SRI integrity constant, and the
htmx.js.version file were not regenerated. CI's 'Build managed JavaScript'
step (build/sync-js.mjs) copies node_modules/htmx.org/dist/htmx.js, so it
shipped 4.0.0 while htmx_script_tag() still emitted the 2.0.10 SRI, failing
tests/Unit/Ui/Html/HtmxTest. Revert htmx.org to the vendored/pinned 2.0.10;
a real htmx 4.x upgrade should be a separate, tested PR.

* fix(htmx): sync version file, tests and docs to vendored 2.0.10
2026-09-28 15:02:18 -04:00
github-actions[bot] fe45229820 ci: refresh security/SQL-interpolation baselines (#8086)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-28 09:01:09 -04:00
dependabot[bot] f26a9ba236 deps(deps): bump the composer group with 3 updates (#8083)
Bumps the composer group with 3 updates: [jfcherng/php-diff](https://github.com/jfcherng/php-diff), [league/oauth2-client](https://github.com/thephpleague/oauth2-client) and [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer).


Updates `jfcherng/php-diff` from 6.16.0 to 7.0.1
- [Release notes](https://github.com/jfcherng/php-diff/releases)
- [Commits](https://github.com/jfcherng/php-diff/compare/6.16.0...7.0.1)

Updates `league/oauth2-client` from 2.9.0 to 2.9.1
- [Release notes](https://github.com/thephpleague/oauth2-client/releases)
- [Changelog](https://github.com/thephpleague/oauth2-client/blob/master/CHANGELOG.md)
- [Commits](https://github.com/thephpleague/oauth2-client/compare/2.9.0...2.9.1)

Updates `friendsofphp/php-cs-fixer` from 3.95.25 to 3.95.26
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.25...v3.95.26)

---
updated-dependencies:
- dependency-name: jfcherng/php-diff
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: composer
- dependency-name: league/oauth2-client
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: composer
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.26
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: composer
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 07:48:42 -04:00
TheWitness d57431371b perf(poller/boost): cached per-data-source field-name lookups, remove batched prefetch (#8078)
* perf(poller/boost): replace batched field-name prefetch with cached per-data-source lookups

poller_prefetch_rrd_field_names() (issue#7534) batched the unused/nt field-name lookups into large IN() lists and rebuilt them on every process_poller_output()/boost_process_local_data_ids() call, so the completion-wait loop re-ran them each pass (DB thrash) and the IN() lists risk crashing some MariaDB/MySQL releases and do not scale to multi-million-data-source installs.

Option B (full): remove poller_prefetch_rrd_field_names() and replace it with two small per-data-source helpers - poller_get_unused_data_source_names() and poller_get_nt_rrd_field_names() - each a single indexed WHERE local_data_id = ? query cached by local_data_id in a caller-held static, so every data source is queried at most once per poller process. Templated field names resolve from the static poller_data_template_field_mappings cache (the Data Template dictates); only non-templated (data_template_id == 0) manual sources hit the per-instance query - a rare exception.

Both process_poller_output() (lib/poller.php) and boost_process_local_data_ids() (poller_boost.php) now use the helpers. Added a WARNING documentation block to both bulk hot-path functions spelling out the query-discipline rules for future maintainers/AI (minimal queries, no query in the per-row loop, avoid large IN() lists and full-table scans, single pass, static template-dictated caching). Updated BoostProcessBugsTest assertions to the new helpers and removed the now-obsolete BoostPrefetchRrdFieldNamesTest integration file.

* fix(poller/boost): split GROUP_CONCAT'd field mappings; guard orphan-name query to templated sources; add coverage

* boost: normalize orphan-name guard to ternary at third call site

The reset_template branch in boost_process_local_data_ids() guarded the
poller_get_unused_data_source_names() call with an if/else block rather
than the ternary used at the other two call sites. Normalize it so all
three sites read identically and the template-id guard is uniform.
2026-09-28 07:48:14 -04:00
dependabot[bot] fa11d52c3f deps(deps): bump the npm group with 2 updates (#8085)
Bumps the npm group with 2 updates: [billboard.js](https://github.com/naver/billboard.js) and [htmx.org](https://github.com/bigskysoftware/htmx).


Updates `billboard.js` from 4.0.3 to 4.1.0
- [Release notes](https://github.com/naver/billboard.js/releases)
- [Changelog](https://github.com/naver/billboard.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/naver/billboard.js/compare/4.0.3...4.1.0)

Updates `htmx.org` from 2.0.10 to 4.0.0
- [Release notes](https://github.com/bigskysoftware/htmx/releases)
- [Changelog](https://github.com/bigskysoftware/htmx/blob/v4.0.0/CHANGELOG.md)
- [Commits](https://github.com/bigskysoftware/htmx/compare/v2.0.10...v4.0.0)

---
updated-dependencies:
- dependency-name: billboard.js
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: htmx.org
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 07:46:57 -04:00
MSS 1ba77fafeb Run nmp install within Cacti installation directory so that install the required cacti-frontend-assets (#8080)
* WEBUI WARNING: Key Cacti Include File "select2.js" missing

WEBUI WARNING: Key Cacti Include File "select2.js" missing. Please locate and replace this file as we checked in "/var/www/cactiuat/include/js/"

* Run nmp install within Cacti installation directory so that install the required cacti-frontend-assets

Run nmp install within Cacti installation directory so that install the required cacti-frontend-assets

* Fix typo in npm install instructions

Corrected grammatical error in README instructions.
2026-09-27 12:24:21 -04:00
MSS 66813e8b01 WEBUI WARNING: Key Cacti Include File "select2.js" missing (#8079)
WEBUI WARNING: Key Cacti Include File "select2.js" missing. Please locate and replace this file as we checked in "/var/www/cactiuat/include/js/"
2026-09-27 09:21:56 -04:00
dependabot[bot] 4f9659fbfd ci(deps): bump the github-actions group with 4 updates (#8076)
Bumps the github-actions group with 4 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action), [trufflesecurity/trufflehog](https://github.com/trufflesecurity/trufflehog) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `github/codeql-action/analyze` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `trufflesecurity/trufflehog` from 3.97.4 to 3.97.5
- [Release notes](https://github.com/trufflesecurity/trufflehog/releases)
- [Commits](https://github.com/trufflesecurity/trufflehog/compare/363923b901c911a9164f50b6c423f47c15372b1c...f714bf454f350590f4a24c3ddb1aef02c35bf5b6)

Updates `github/codeql-action/upload-sarif` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: trufflesecurity/trufflehog
  dependency-version: 3.97.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 07:30:44 -04:00
TheWitness f030e5cc60 feat(login_providers): form field hints, cert/private-key display, and encrypted storage (#8074)
* feat(login_providers): add form hints, cert/private-key display, and Weblate strings

- Adds example.com-style placeholders across the LDAP/SAML2/OpenID Login Provider fields (server, DN/group DN/search filter, entity IDs, SSO/SLO/discovery URLs, group claims), so the expected syntax is visible without opening the docs.

- Adds a 'textcert' form method (form_cert_box()) for pasted PEM certificates: styled like filepath/dirpath, it parses the certificate and shows its expiration date, using the deviceUp/deviceDown status classes ('Good Till: YYYY-MM-DD' / 'Expired: YYYY-MM-DD'), or flags invalid input. Applied to SAML2's SP/IDP certificate fields.

- Adds a 'privkey' form method (form_privkey_box()) for the SAML2 SP Private Key: never redisplays the stored value (leaving it blank on save keeps the existing key); the status area shows '[stored]' or '[not set]' instead.

- Adds cacti_encrypt_secret()/cacti_decrypt_secret() (lib/auth.php), an AES-256-CBC envelope with a per-installation key persisted in \settings\ on first use, modeled on plugin_servcheck's servcheck_encrypt_credential()/servcheck_decrypt_credential(). The SP Private Key is now encrypted at rest; AbstractLoginProvider::existingParameter()/encryptOrKeepExisting() preserve the existing encrypted value when the privkey field is left blank.

- Regenerates locales/po/cacti.pot so Weblate picks up the new strings.

- Adds tests/Unit/Security/Auth/LoginProviderSecretFieldsTest.php (10 tests) covering the encrypt/decrypt round-trip, IV uniqueness, failure modes, and both new form widgets' rendering.

Verified: PHPStan clean, full Pest Security suite passes (835 tests including the 10 new ones), php-cs-fixer clean.

* fix(login_providers): never redisplay stored cert/key, preserve on blank save

form_cert_box() previously echoed the stored PEM certificate back into the textarea, and form_save() overwrote sp_x509cert/idp_x509cert with an empty string whenever the field was submitted blank - unlike sp_private_key, which already had blank-preserves-existing handling.

- form_cert_box() now behaves like form_privkey_box(): the textarea is always rendered blank (only repopulating from a failed same-submission's SESS_FIELD_VALUES, never the stored certificate); the expiration/status indicator is still computed from the stored value.

- Added AbstractLoginProvider::keepExistingIfBlank(), the non-secret counterpart to encryptOrKeepExisting(): a blank submission keeps the existing stored value instead of clearing it. Wired into SamlLoginProvider::collectParameters() for both sp_x509cert and idp_x509cert.

- Extended tests/Unit/Security/Auth/LoginProviderSecretFieldsTest.php: form_cert_box() never leaks the stored certificate into the rendered HTML, and keepExistingIfBlank()/encryptOrKeepExisting() resolve correctly for both a fresh submission and a blank field with no existing row (no database access needed for either case).

Verified: PHPStan clean, full Pest Security suite passes (838 tests), php-cs-fixer clean.

* fix(login_providers): address Copilot review round 1 + CI formatting

- CI: fixed a php-cs-fixer docblock indentation mismatch in lib/auth.php.

- Upgraded to actual authenticated encryption: the secret envelope is now AES-256-GCM (IV + 16-byte tag + ciphertext) instead of plain AES-256-CBC, which had no MAC/tag - a tampered database value now fails to decrypt instead of silently producing modified plaintext.

- cacti_secret_key() now fails closed on a corrupt persisted key (not valid base64, or not exactly 32 bytes) by throwing, instead of silently substituting an unpersisted random key that would desync from every existing/future ciphertext. cacti_decrypt_secret() catches this and degrades to false (logged); cacti_encrypt_secret() lets it propagate so a broken key never results in a secret being silently stored insecurely or lost.

- Added a backward-compatible read path for the SP Private Key: a provider whose parameters still hold the raw PEM from before this feature existed (cacti_decrypt_secret() returns false because it isn't a valid envelope) now falls back to using that value directly instead of losing the key. AbstractLoginProvider::encryptOrKeepExisting() opportunistically re-encrypts such a legacy value the next time the form is saved (even with the privkey field left blank), rather than requiring the admin to re-paste it.

- form_cert_box()/form_privkey_box() no longer repopulate from SESS_FIELD_VALUES: form_input_validate() writes that session value on every submission (including a successful save), and the save's redirect runs before bottom_footer() clears it - the immediate next page load was echoing the just-submitted plaintext private key/certificate back into the textarea. Both fields are now unconditionally blank.

Verified: PHPStan clean, full Pest Security suite passes (841 tests, 6 new), php-cs-fixer clean (fresh cache, whole-repo dry-run).
2026-09-24 20:55:29 -04:00
Ekaterine papava 5f7d5083cc Translated using Weblate (Georgian)
Currently translated at 47.3% (2498 of 5272 strings)

Translation: Cacti/core
Translate-URL: https://translate.cacti.net/projects/cacti/core/ka/
2026-09-24 16:51:23 -05:00
TheWitness a52368125e feat(auth): rename User Domains to Login Providers, add SAML2 and OpenID Connect (#8066)
* feat(auth): rename User Domains to Login Providers

Collapses user_domains/user_domains_ldap into a single login_providers table with a JSON parameters column, migrated in place during the 1.3.0 upgrade. Renames the admin page, menu, breadcrumbs, and help links to match.

* feat(auth): rework login providers as Cacti\Auth classes

Introduces LoginProviderInterface with credential (LDAP/AD) and redirect (SAML2/OpenID) flavors, replacing the procedural domains_login_process()/domains_ldap_*() functions. Adds blank-field group membership gating shared by every provider type.

* feat(auth): add SAML2 and OpenID Connect login providers

Adds onelogin/php-saml and firebase/php-jwt, a new cacti_http() SSRF-hardened outbound request helper for every OpenID discovery/token/JWKS call, and login_sso.php to handle the redirect-based SSO flow. Login page now shows a button per enabled SAML2/OpenID provider, and per-provider auth cookie opt-out is enforced on both the password and SSO login paths.

* chore(i18n): regenerate cacti.pot

* feat(auth): add Local and Basic auth as Cacti\Auth providers

LocalAuthLoginProvider (CredentialLoginProviderInterface) and BasicAuthLoginProvider (new PreAuthenticatedLoginProviderInterface, since Basic Auth has no password for Cacti to check) are thin adapters over the existing local_auth_login_process()/basic_auth_login_process() functions, which are left untouched to avoid disturbing their regression-tested lockout/timing/rehash behavior.

* feat(auth): move local password verification into LocalAuthLoginProvider

secpass_login_process() and local_auth_login_process() are retired; their lockout, constant-time verification, forced-password-change, and rehash-on-login logic now live end to end in the class (verifyCredential/enforcePasswordPolicy/rehashIfNeeded). secpass_check_pass() stays a shared lib/auth.php function since password change/reset/user_admin also call it outside any login flow. Local auth still has no login_providers row: isEnabled() is always true and it cannot be disabled or deleted.

* feat(auth): move Login Provider CRUD into Cacti\Auth classes

AbstractLoginProvider gains static deleteById()/enableById()/disableById()/makeDefaultById()/persist(); each concrete provider owns its own collectParameters(), dispatched via LoginProviderFactory. login_providers.php's form_save()/form_actions() call these instead of doing raw SQL, and the retired provider_remove()/provider_disable()/provider_enable()/provider_default()/provider_collect_parameters() procedural functions are gone. Local/Basic auth are unaffected: they never extend AbstractLoginProvider, so they structurally cannot expose delete/disable.

* fix(auth): address Copilot review findings on SSO/SAML/OIDC security

Fixes callback/ACS/SLS/metadata URLs that used the raw provider id instead of the login realm (broke every SSO login), adds InResponseTo correlation for SAML and sub/azp validation for OIDC, requires HTTPS for OIDC discovery/endpoints, pins cacti_http() connections against DNS rebinding via CURLOPT_RESOLVE, exempts login_sso.php from the authenticated-page redirect guard and gates it on auth_method, wraps provider dispatch in a exception boundary, adds a dedicated SAML SLO path instead of routing it through login completion, makes remember-me an explicit opt-in for SSO, fixes disabled/deleted providers not revoking existing auth cookies, restricts the default-realm action to LDAP/AD, and fixes the 1.3.0 migration silently enabling group enforcement for installs that had it off. Also updates the E2E seed script for the login_providers schema.

* chore(auth): refresh CI security baselines and document no-op validation

Regenerate the sink inventory baseline for the new/renamed lib/Auth/* and login_providers.php files, and add '// no-validation:' comments on the intentionally-unconstrained admin/free-text/secret fields (LDAP DNs/filters, SAML certs/keys, OIDC client credentials, provider display fields) flagged by the validator constraint ratchet.

Refreshes the validator baseline to drop the deleted user_domains.php entries.

* refactor(ldap): remove dead DB-lookup path from Ldap::__construct

The domain_id>0 branch duplicated the JSON-parameter-to-property mapping already owned by LdapLoginProvider::buildLdap(), and nothing constructs Ldap with a real id anymore (all call sites pass 0). Drop the parameter and the branch so Ldap stays a pure connection/protocol class with no DB coupling.

Also fixes a latent bug: group_require is a typed bool property, but LdapLoginProvider::authenticate() assigned it an int (1/0) from a strict_types=1 file, which throws a TypeError on every LDAP/AD login attempt. Assign a real bool instead.

* fix(auth): fix LDAP login, upgrade ordering, and hardening guard failures

LdapLoginProvider compared LdapError::GetErrorDetails()'s int error_num against the string '0' with strict equality, so it never matched and every LDAP/AD login was rejected regardless of valid credentials. Compare against int 0 instead.

install/upgrades/1_3_0.php called login_providers_convert_1_3_0() (which reads and then drops user_domains/user_domains_ldap) before the pre-existing legacy ALTER TABLE statements that add the debug/network_timeout/bind_timeout columns those reads depend on, so upgrades failed once the tables were already gone. Moved the call to run after those ALTERs.

OpenIdLoginProvider::complete() read state/code from raw \ instead of gnrv(), tripping the hardening pattern guard; switched to the request helper.

* style(auth): apply php-cs-fixer formatting and normalize line endings to LF

Runs php-cs-fixer across the new lib/Auth/* classes, login_providers.php, login_sso.php, and the other touched files, fixing indentation/alignment/docblock style to match project convention. Also normalizes several files that had picked up CRLF line endings back to the LF the project's .php-cs-fixer.php config (setLineEnding) and the rest of the tracked tree actually use.

* fix(auth): address second Copilot review round (9 high/medium findings)

- Make login_providers_convert_1_3_0() resumable: CREATE TABLE IF NOT EXISTS and INSERT ... ON DUPLICATE KEY UPDATE so an interrupted upgrade can be re-run instead of leaving a half-migrated, unrecoverable table.

- SamlLoginProvider::complete() now fails closed when no saved AuthnRequest ID exists instead of passing null to processResponse(), which skipped InResponseTo validation and accepted unsolicited IdP-initiated assertions.

- LoginResult gains an isCredentialFailure flag; only a genuine wrong-password LDAP bind (LdapError::Failure) counts toward the lockout counter now, not connection errors or group-membership denials.

- Fixed button_label/debug being read from the JSON parameters column in OpenIdLoginProvider/SamlLoginProvider/LdapLoginProvider when they are actually top-level login_providers columns; AbstractLoginProvider now hydrates and exposes both.

- Removed SamlLoginProvider's wantMessagesSigned, which silently required the outer SAML message (not just the assertion) to be signed, rejecting IdPs that only sign the assertion as the UI promises.

- Added getTemplateUserId() to CredentialLoginProviderInterface and implemented it on LocalAuthLoginProvider so the contract (not just incidental AbstractLoginProvider inheritance) guarantees the method lib/auth.php calls.

- cacti_http()'s CURLOPT_RESOLVE entries now bracket IPv6 literals correctly, and safe-IP validation strips parse_url()'s brackets before checking, fixing IPv6 target pinning.

- login_providers.php's type dropdown now swaps the Full Name attribute default (cn/displayName) on an actual user-driven type change, not on page load, so a new Active Directory provider does not silently save the LDAP default.

- Extracted auth_apply_group_login_opts() and call it from both auth_login.php and login_sso.php, so SSO logins honor a user's group-derived login_opts the same way password logins already do.

* fix(auth): fix PHPStan level 8 errors and pest harness stub drift

Test harness fix: the LoginProvidersLoginRealmValidationTest stub LoginResult class was missing the isCredentialFailure property added to the real class, causing 'Undefined property' warnings that broke the harness's JSON output. Added the property and set it on the simulated bad-password failure.

PHPStan (48+ errors, all newly introduced by this branch's rewrite, never actually verified until now): narrowed db_fetch_row_prepared()/db_fetch_assoc()'s array|bool (including bare true, which survives a '?: []' short-circuit - fixed with explicit is_array() checks instead) at every call site across auth_login.php, login_sso.php, lib/auth.php, lib/Auth/LocalAuthLoginProvider.php, lib/Auth/LoginProviderFactory.php, and install/upgrades/1_3_0.php.

Fixed OpenIdLoginProvider::discover()'s return shape to match its declared docblock instead of returning the raw decoded JSON. Fixed lib/functions.php's cacti_http() to narrow curl_exec()'s string|bool result with is_string() (not ===false, which still leaves 'true' as a residual type) and guard / as non-empty for curl_setopt_array()'s stubs.

Fixed a real bug in SamlLoginProvider::processLogout(): processSLO(true) was passing 'true' as \ (the first parameter) instead of \ (the fifth), so it always redirected/exited internally with the default \=false, making Cacti's own logout.php never regain control. Now calls processSLO(keepLocalSession: true, stay: true) and is typed ': never' to match; login_sso.php's now-genuinely-unreachable break statements after processLogout()/initiate() are removed.

Fixed login_providers.php passing a expression (not a variable) to inject_form_variables()'s by-reference parameter.

Verified: PHPStan analyse clean (0 errors), full Pest Unit/Security suite passes (128 tests), hardening guard/sink inventory/validator ratchet all still clean.

* fix(auth): fix ambiguous column and array+bool crash in user_log filter

The user filter dropdown query selected a bare 'id' column after joining user_auth and login_providers, which is ambiguous now that login_providers also has an id column (unlike the old user_domains.domain_id) - MySQL rejected the query outright. Qualified as ua.id.

Also narrow db_fetch_assoc()'s array|bool result before the \ + \ + \ merge, which was throwing a TypeError whenever the query failed instead of surfacing the real SQL error.

* fix(auth): address third Copilot review round (5 findings + tests)

- AbstractLoginProvider::persist() now disables the template account for every provider type; SAML2/OpenID also copy it via user_copy() in login_sso_complete() and were left directly usable as a local login.

- cacti_http_resolve_safe_ips() now detects IPv4-mapped IPv6 literals (::ffff:0:0/96) via inet_pton() and validates the embedded IPv4 address, closing an SSRF bypass via e.g. ::ffff:169.254.169.254.

- login_providers_convert_1_3_0() now tracks db_install_execute()'s status for every row and only drops the legacy user_domains/user_domains_ldap tables once every row (and the final row count) is confirmed migrated.

- Ldap::Getcn() now looks up ldap_get_entries()'s always-lowercased attribute keys case-insensitively while still storing the result under the originally-requested key, fixing AD's displayName (and any other mixed-case configured attribute) always resolving to a blank full name.

- SamlLoginProvider::processLogout() now redirects to the LogoutResponse URL processSLO() returns for a LogoutRequest (stay:true), instead of discarding it and always redirecting to Cacti's own logout page - federated logout was never actually acknowledged to the IdP.

- Added tests/Unit/Security/Auth/OpenIdLoginProviderTest.php (13 tests) covering verifyIdToken() signature/issuer/audience/azp/nonce validation, discover()'s HTTPS enforcement, and complete()'s callback-state and UserInfo-subject-matching checks, against a locally-generated RSA keypair/JWKS.

- Regenerated the sink inventory baseline for the new header_redirect call in SamlLoginProvider::processLogout().

Verified: PHPStan clean, full Pest Security/Auth suite passes (830 tests including the 13 new ones), php-cs-fixer/hardening-guard/sink-inventory/validator-ratchet all clean.
2026-09-24 17:51:18 -04:00
TheWitness 38c1d05f08 perf: cache realpath(CACTI_PATH_RRA) in data_source_path_within_rra() (#8070)
* perf: cache realpath(CACTI_PATH_RRA) in data_source_path_within_rra()

* perf: align RRA-root realpath cache with 1.2.x's array-keyed pattern
2026-09-24 14:51:39 -04:00
TheWitness 28aba6667e Resolving issue #8063: Support searching by graph ID on Graphs and Aggregate Graphs pages (#8067)
* Resolving issue #8063: Support searching by graph ID on Graphs and Aggregate Graphs pages

* Restrict graph-ID matching to all-digit search values to avoid MySQL leading-digit coercion

* Fix PHP CS Fixer coding-standards violations flagged by CI
2026-09-24 10:21:24 -04:00
TheWitnessandcopilot-swe-agent 2d516ca0a9 security: consolidate additional verified GHSA fixes for develop (batch 2) (#8065)
* security: contain data source paths within the RRA directory (GHSA-9qff-xccr-rrc5)

* security: escape executable path in exec_background on non-win32 (GHSA-cv7j-p6fh-j4q7)

* security: stop disabled accounts authenticating via remember-me cookie and revoke on disable (GHSA-pqrp-v752-mc4f)

* security: prefer base_url over client Host for the force_https redirect (GHSA-3466-fp7g-c9rf)

* changelog: add fragments for GHSA-9qff-xccr-rrc5, GHSA-cv7j-p6fh-j4q7, GHSA-pqrp-v752-mc4f, GHSA-3466-fp7g-c9rf

* security: address review findings on GHSA batch 2 (develop)

- lib/dsstats.php, poller_maintenance.php: route through
  get_data_source_path() instead of expanding data_source_path directly,
  so the RRA containment check applies to these consumers too (GHSA-9qff)
- lib/functions.php: data_source_path_within_rra() now also validates
  the realpath of each existing ancestor segment, closing a symlink
  pivot that the purely lexical check missed (GHSA-9qff)
- include/global.php: fail closed instead of falling back to SERVER_NAME
  when base_url is unset/invalid, removing the residual open-redirect
  path (GHSA-3466)
- lib/html_utility.php: cacti_force_https_host()/
  cacti_build_https_redirect_url() now parse a scheme-less base_url
  (matching lib/reports.php's own normalization) and preserve a
  configured non-default port instead of silently dropping it (GHSA-3466)
- tests: move DataSourcePathContainmentTest.php into
  tests/Unit/Security/PathTraversal/, and switch its fixtures from a
  fictional CACTI_PATH_RRA path to a real temp directory so the new
  realpath()-based checks (including a symlink-pivot regression case)
  can actually run

* test: fix CACTI_PATH_RRA constant collision in DataSourcePathContainmentTest

CI showed 2 failures: RrdProxyProtocolTest.php runs earlier in the same
Pest process and already defines CACTI_PATH_RRA to a fictional,
non-existent '/var/lib/cacti/rra', so our own definition guard never
ran and realpath(CACTI_PATH_RRA) returned false for every assertion,
failing the two tests that expect containment to be true.

Now ensure the directory backing CACTI_PATH_RRA actually exists on
disk regardless of who defined the constant first, and gracefully
skip only the fixture-dependent assertions if this process can't
create it (e.g. no permission on a system path).

* test: refresh sink inventory baseline for exec_background rename

5fbe90fb4 renamed \ to \ in lib/poller.php's
exec() calls (GHSA-cv7j-p6fh-j4q7) but never regenerated the tracked
sink inventory, so the 'sink and architectural hotspot guard' CI check
has been failing on this branch since that commit. Regenerated via
tests/security/build_sink_inventory.sh | tr -d '\r' | LC_ALL=C sort;
diff confirms only those two lines changed.

* fix: address CI failures on GHSA batch 2 (develop)

- lib/auth.php: verify the remember-me token before revoking a disabled
  account's cache row, so a forged token no longer triggers a database
  write (fixes AuthSystemRegressionIntegrationTest)
- apply php-cs-fixer coding standard fixes (comment style, phpdoc
  alignment, list-to-short-array syntax) flagged by the locked
  integration check
- refresh the architectural hotspots baseline for the exec_background
  safe_filename rename

* fix: update PHPStan baseline for reordered check_auth_cookie and get_data_source_path

- lib/auth.php: reordering the disabled-account check after the token
  match moved one 'enabled' offset access to a new line, bumping the
  file's expected occurrence count from 3 to 4
- lib/functions.php: the GHSA-9qff-xccr-rrc5 containment log line reads
  \['data_source_path'], a genuinely new array|bool access
  not previously tracked

---------

Co-authored-by: copilot-swe-agent <copilot-swe-agent[bot]@users.noreply.github.com>
2026-09-24 10:06:01 -04:00
github-actions[bot] a1fab6816f ci: refresh security/SQL-interpolation baselines (#8059)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-23 13:42:50 -04:00
Thomas VincentandTheWitness d3d99bb08a test(cdef): enforce resolver coverage (#7974)
* test(cdef): enforce resolver coverage

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

* ci(cdef): pin cache action correctly

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

* test: isolate CDEF coverage bootstrap

* fix(cdef): fail closed on invalid recursive definitions

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

* fix(cdef): log corrupt resolver records

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

* fix(cdef): bound and isolate recursive resolution

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

* fix(cdef): preserve resolver failure state

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

* fix(cdef): isolate invalid graph expressions

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

* fix(cdef): abort unsafe consumer operations

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

* fix(cdef): close consumer failure paths

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

* fix(cdef): make aggregate updates atomic

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

* fix(cdef): keep repair queries auditable

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

* fix(cdef): bind bulk deletes and escape item types

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

* fix(cdef): guard corrupt types and repair reporting

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

* fix(aggregate): propagate push-out failures

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

* fix(aggregate): document mutable message checks

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

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-09-23 13:38:51 -04:00
TheWitness af8449e487 Fix RECACHE ASSERT loop caused by SNMP uptime selection diverging from spine (develop) (#8058)
* fix: match spine's unconditional engine-time preference for SNMP uptime recache (develop)

PR #7982 changed cacti_snmp_select_uptime() to prefer whichever of sysUpTime
and snmpEngineTime is numerically larger. Spine's own reindex-assert re-check
(poller.c) has no such magnitude comparison - it always prefers a numeric
engine time, only falling back to sysUpTime when the engine OID isn't present.

For any device where snmpEngineTime is legitimately smaller than sysUpTime
(e.g. the SNMP agent/engine restarted more recently than the OS, a common,
unremarkable scenario), lib/poller.php's update_reindex_cache() now picks
sysUpTime as the recache baseline while spine always re-derives its live
comparison value from the smaller engine time. Every poll then reports
"RECACHE ASSERT FAILED" and queues a reindex; Cacti recomputes the baseline
the same way, so it never converges - an infinite RECACHE ASSERT loop that
looks like a spine bug even though spine is unchanged.

Fix: add an opt-in $prefer_engine_time parameter to cacti_snmp_select_uptime()
that keeps the existing wall-clock rejection (issue#7342) but skips the
"prefer whichever is larger" comparison, matching spine's rule exactly. Use it
for the two call sites that must stay consistent with spine's own live
re-check: lib/poller.php's recache baseline, and both uptime reads in
cmd.php (update_system_mibs()'s host.snmp_sysUpTimeInstance update, and
ping_and_reindex_check()'s own PHP-poller equivalent of spine's reindex
re-check). The display-only call sites (lib/api_device.php,
lib/api_automation.php) keep the existing wrap-aware "prefer larger" logic,
since they aren't compared against a persisted, spine-shared baseline.

Ported from the 1.2.x fix (#8057). Added tests covering the new parameter and
asserting both call sites opt into it.

* test(snmp): cover prefer_engine_time in the coverage-gated suite

phpunit-snmp.xml's "SNMP coverage" testsuite only runs
SnmpCoverageTest.php, not SnmpUptimeSelectionTest.php where the new
$prefer_engine_time assertions were added. The engine-time-preference
branch in cacti_snmp_select_uptime() was never executed under the
100% line coverage gate, so the "lib/snmp.php 100% coverage" check
failed at 99.8%.

Mirror the same prefer_engine_time assertions into SnmpCoverageTest.php
so the coverage-gated suite exercises the new branch.

* fix: match spine's lack of wall-clock awareness for prefer_engine_time paths

The wall-clock rejection guard ran before the prefer_engine_time branch,
so an OpenBSD-style agent (engine time == Unix clock) still fell back to
sysUpTime on the spine-facing paths even with prefer_engine_time=true.
Spine's own re-check has no wall-clock awareness at all - it unconditionally
prefers any numeric engine time - so that class of device kept the RECACHE
ASSERT loop. Move the prefer_engine_time short-circuit before the wall-clock
check so those paths replicate spine exactly; the wall-clock rejection still
protects the display-only call sites.

* test(snmp): fix stale wall-clock assumption in prefer_engine_time coverage test
2026-09-23 11:59:35 -04:00
TheWitness f956ea5a5c Convert generic <select> elements to select2(), with new class helpers (#8053)
* feat: convert generic <select> elements to select2, with new class helpers

Replaces the jQuery UI selectmenu() catch-all in setSelectMenus() with select2,
applied uniformly to single and multi selects. Fewer than 10 options disables
the search box (minimumResultsForSearch: Infinity); 10 or more enables it -
same threshold now applied to the existing select2/select2-multi classes.

New layout.js class helper: select2-multi-count, a multi-select whose
selection summary reads "N Selected"/"All Selected" instead of one chip per
option (generic multiSelectAllText/multiSelectCountText strings in html.php,
overridable per-field via data-select-all-text/data-select-count-text/
data-select-all-value). graph_template_id (html_graph.php x2, html_tree.php)
now uses it in place of the jquery.multiselect-based .graph-multiselect,
preserving the existing "All Graph Templates"/"N Templates Selected" wording
and the -1 "all" sentinel behavior (reimplemented via select2:select/unselect
events in setSelectMenus()). The old .graph-multiselect jquery.multiselect
init block is left in place (now unreachable for core's own markup) in case
a plugin/theme still emits that class itself.

select2-callback is reworked from its previously-unused static-URL contract
into an ajax-backed replacement for the legacy .drop-callback/makeCallbacks()
autocomplete widget: data-action + data-variables build the query the same
way makeCallbacks() already did (pageName + action + live values of other
named fields + search term), and data-callback still names a JS function to
invoke on selection, matching form_callback()'s existing $on_change/$class
contract. form_callback() only renders the new <select> markup when its
$class parameter contains select2-callback (opt-in); every existing caller
that doesn't request it keeps the exact legacy output. html_graph.php's
host_id filter (both places) opts in. form_multi_dropdown() gained optional
$select_all_text/$select_count_text/$select_all_value parameters (all
additive/backward compatible) to plumb the graph_template_id wording through.

Compatibility: an org-wide code search found no plugin depending on the
.select2-callback/.graph-multiselect/.drop-callback/makeCallbacks() internals
being changed here (form_callback()'s default output is unchanged; the
.graph-multiselect jquery.multiselect init stays available). Everywhere else
in core that assumed the old jQuery UI selectmenu widget on elements now
covered by the broadened catch-all (disableField()/enableField(), the SNMP
security-level/protocol refresh logic, realtime.js's ds_step/graph_start/size
refresh, api_aggregate.php's field enable/disable, and html_reports.php's
branch_id/site_id/host_template_id handling) now branches on
.select2-hidden-accessible and uses select2's own refresh mechanism
(.trigger('change.select2')) instead, alongside the untouched legacy
selectmenu branch. #user_language and #i18n_default_language (which get
their own bespoke flag-icon languageselect widget) are excluded from the
catch-all, same as before.

The install wizard (install/install.js) has its own independent selectmenu
initialization and is intentionally left untouched.

* fix: use json_encode for select2 multi-select JS translation strings

* fix: make select2 initializers idempotent to stop duplicate widgets

applySkin() (which calls setSelectMenus(), where every select2/select2-multi/
select2-multi-count/select2-callback/catch-all initializer lives) runs more
than once per page view - once from cactiReady() on initial load, and again
from the generic AJAX-completion handler after any loadUrl()/postUrl() call,
which most list/filter pages already trigger during their own initial load.

Since calling .select2() a second time on an element that already has it
initialized creates a second, independent .select2-container next to the
first (select2 does not no-op or tear down the previous instance), every
affected <select> ended up rendering twice. Guard every initializer (plus
the graph_template_id -1 sentinel's event bindings, now namespaced and
unbound first) so a second setSelectMenus() pass skips elements already
marked .select2-hidden-accessible.

* fix: match select2 dropdown styling to the dark theme's real button look

border-radius was 4px (should be 6px, matching .ui-corner-all used
everywhere else); the selection box and dropdown panel were flat black
instead of this theme's actual .ui-button/.ui-widget-content backgrounds
(a subtle gradient-highlight image and a repeating texture image,
respectively). Also add a min-width and force the selected text/dropdown
option list to a single line instead of wrapping.

* fix: match select2 dropdown styling to each theme's real button look

Same fix as the earlier dark theme commit, applied to the remaining themes.
Every theme's select2 override previously used generic placeholder colors
(border-radius: 4px, background: #ffffff/#222222) that did not match that
theme's actual .ui-state-default/.ui-button/.ui-widget-content styling:

- cacti, carrot, hollyberry, raspberry, modern, paw: selection box now uses
  the real button gradient-image background instead of flat white; dropdown
  panel background already matched and is unchanged.
- sunrise: selection box background corrected to transparent (matching its
  real button style) and the dropdown panel to its real black background,
  instead of flat white for both.
- paper-plane: selection box background corrected to this theme's real
  near-black button color instead of flat white.
- midwinter: switched from hardcoded generic colors to this theme's own
  CSS custom properties (var(--border-color), var(--background-content),
  var(--text-color-lighter), etc.), matching jquery-ui.midwinter.css.

border-radius is now 6px everywhere, matching .ui-corner-all. All themes
also gained a min-width and forced the selected text/dropdown option list
onto a single line instead of wrapping.

* fix: default select2's dropdownAutoWidth to true

The legacy jQuery UI selectmenu widget always sized its open dropdown to fit
the widest option, regardless of how narrow the closed control itself was.
Select2 defaults to matching the dropdown's width to the control's, so wide
option text was wrapping/truncating inside a narrow panel. Set this as a
global default once instead of touching every individual select2() call.

fix: destroy any existing select2 widget before re-initializing

applySkin() runs more than once per page view. The existing
:not(.select2-hidden-accessible) guards were meant to make re-running the
select2 initializers a no-op, but duplicate widgets were still being
reported. Explicitly tear down any select2 instance still attached right
before the initializers run again, so a widget can never end up duplicated
regardless of why applySkin() re-ran.

fix: don't select2-ify a select a plugin already widget-ified with selectmenu

Symmetric to the above: if a plugin's own script already deliberately
applied jQuery UI selectmenu to one of its selects (a few do, for their own
dynamically-created fields), skip converting it to select2 too.

* fix: convert the Device filter's legacy drop-callback widget to select2-callback

html_host_filter()'s autocomplete-enabled branch never got the select2
treatment applied to every other filter field - it still built its own
<input class='drop-callback'> wired up by the older makeCallbacks()/jQuery UI
autocomplete widget (styled with the legacy ui-selectmenu-button markup),
instead of opting into form_callback()'s newer select2-callback rendering like
everything else. Left side-by-side with now-modernized neighboring filters,
this old widget stood out as a leftover "old select". Switch it to the same
select2-callback select markup (data-action='ajax_hosts',
data-variables='site_id') the 1.2.x branch already uses, reusing the existing
ajax_hosts handlers and get_allowed_ajax_hosts()'s id/label response shape.

* fix: restore select2's gradient after closing without selecting, shrink its height, and drop the now-unneeded min-width

Every theme's active/open select2 style also matched .select2-container--focus,
but select2 leaves that class on the container after the dropdown closes (it
only drops --open), so the flat "active" background stuck around until the
next selection instead of the gradient returning right away. Scope the active
look to --open only.

Also shrink the single-select control from select2's stock 28px down to 24px
(and its arrow/rendered text to match) so it sits closer to the jQuery UI
button height it's replacing, and drop the min-width: 160px placeholder added
earlier to keep wide option text from being clipped - dropdownAutoWidth now
sizes the open dropdown to its content regardless of the closed control's
width, so the constraint is no longer needed.

* feat: add configurable select2 search threshold; drop autocomplete_enabled on develop

Add the same Select Search Minimum Rows setting added on 1.2.x, wiring it
through as the select2SearchRows JS global (include/global_session.php) and
replacing the hardcoded 10-option cutoff in every select2 initializer in
layout.js.

Remove Autocomplete Enabled entirely on develop: it only ever existed to let
the Classic theme opt out of the ajax-backed callback widgets, and Classic
doesn't exist as a develop theme. html_host_filter() and the Graph Items data
source field now always use the ajax-backed rendering (select2-callback /
drop_callback) unconditionally instead of branching on the removed setting.

* fix: keep the first 20 select2 results visible before scrolling

Select2's stock results list only guaranteed ~7-8 rows (200px) before
scrolling. Pin each result row to a consistent 24px (matching the shrunk
control height) and size the list's max-height to fit 20 of them before the
scrollbar kicks in.

* fix: repair layout.js corruption from the select2SearchRows commit, and exclude .multiselect from the catch-all

A faulty automated edit in 3cbc5a75 (feat: add configurable select2 search
threshold) matched the wrong text in 4 places and clobbered unrelated code
instead of the intended minimumResultsForSearch lines:
 - deleted the fa-remove icon-class swap and mangled the `if (!theme)` check
 - deleted setupPageTimeout()/CsrfMagic.end()
 - left a stray bare `select2SearchRows` statement before the themeReady() call
 - deleted the #password/#password_confirm delayKeyup wiring and broke the
   showPage deferred's closing brace

Restored all four to their pre-corruption behavior and applied the intended
select2SearchRows threshold to the actual 4 select2 init blocks this time
(select2, select2-multi, select2-multi-count, and the generic catch-all).

While in there: the catch-all's '.multi-select'/'.graph-multiselect'
exclusions never matched anything real - form_multi_dropdown() always tags
its <select multiple> with plain 'multiselect', so every field using the
older jquery.multiselect plugin (Automation's day/month pickers, Consolidation
Functions, Selective Debug, etc.) was getting select2-ified on top of its own
dedicated multiselect() init. Exclude '.multiselect' instead.

* feat: checkbox-styled option rows for select2-multi-count dropdowns

Gets closer to the old jquery-multiselect look the count-style multi-selects
(Graph Templates, etc.) replaced: each option row in the open dropdown now
shows a checkbox-style indicator instead of plain text, driven by
[aria-selected] via CSS so it stays in sync with select2's own selection
state without any extra JS bookkeeping. closeOnSelect stays off so multiple
options can still be picked without the dropdown closing between clicks; the
closed control still shows the "N Selected"/"All Selected" summary text.

* fix: match multi-select control height to single-selects and add its missing dropdown arrow

select2's stock multi-select box only had a min-height with no matching
override, so it stayed taller than every single-select control next to it;
select2-multi-count fields never actually show real per-option chips (the
rendered area always gets replaced with "N Selected"/"All Selected" text),
so it's safe to tighten this to match.

select2 also only renders a .select2-selection__arrow element for
single-selects - multi-selects (Templates, Days of Week, etc.) got none by
default, so they looked handle-less next to every single-select control. Add
a matching CSS-only arrow via ::after (flips on select2-container--open,
same as the single-select's).

* fix: stop select2's multi-select inline search box from collapsing to near-zero width

Select2 sizes the inline search input it embeds in .select2-selection__rendered
by measuring the box's natural layout; my earlier height-matching CSS
(zeroed padding, a fixed line-height, and extending the single-select's
overflow:hidden/text-overflow:ellipsis truncation to multi-selects too) broke
that measurement, so the search field rendered as a barely-visible sliver
sitting inline next to the "N Selected" text instead of expanding to a usable
width.

Back off to a lighter touch: only nudge min-height down a little (no padding/
line-height overrides), and keep the overflow/ellipsis truncation scoped to
single-selects, which is where it was actually needed (short "N Selected"/
"All Selected" labels on multi-selects don't need it and it wasn't there
before this session's changes).

* fix: hide the inline search box on select2-multi-count controls

Reducing padding/line-height (previous attempt) didn't stop the box from
showing - select2's inline search-while-typing input for multi-selects still
rendered as a tiny stray rectangle next to the "N Selected" text and kept
inflating the control's height, since that input's own margin/height are
part of the rendered area's natural content flow regardless of the parent's
box model.

select2-multi-count fields always replace the rendered area with summary
text and rely on checkboxes in the open dropdown for picking options, so the
inline search box serves no purpose here - hide it outright via the adjacent-
sibling selector (select.select2-multi-count + .select2-container), which
also lets the control's height settle back down to match single-selects
without needing to fight select2's own sizing math.

* fix: let select2-multi-count's search box work again once opened; fix vertical alignment

Hiding the inline search box outright also removed the only way to search a
multi-select's open dropdown (multi-select mode has no separate dropdown-only
search box - the inline one filters the open list too). Scope the hide to
:not(.select2-container--open) instead, so it stays out of the way while
closed but reappears once the dropdown is open.

Also wrap the "N Selected"/"All Selected" label in its own span
(select2-count-label) instead of writing bare text into
.select2-selection__rendered, so its line-height/truncation can be
controlled independently of whatever else (the search box) shares that
container - this was causing the text to sit off-center vertically.

* fix: keep the multi-select control's height fixed when its search box opens

Even scoped to select2-container--open, the inline search <li> was still
sitting inside .select2-selection__rendered as a normal block-level sibling
of the "N Selected" label span, so it pushed the label onto its own line and
grew/shifted the whole control upward whenever the dropdown opened - visible
proof the search box was structurally part of the "selection" element, not
just visually overlapping it.

Take it fully out of flow with position: absolute (sized to the control via
top/left/right/height: 100%, overriding select2's own inline width/height on
the actual <input>), so it can never affect the parent's layout regardless of
when it's shown. Also switch the control to flexbox with align-items: center
for reliable vertical centering of the label instead of a guessed
line-height, and give it the same 8px left inset the single-select's text
already has.

* fix: stop the multi-select's overlaid search field from painting over the label

The generic .select2-search--inline .select2-search__field rule gives search
boxes a solid background (matching the dropdown panel's search box, where
that's correct) - but once resized to fill the whole control via absolute
positioning, that same solid background completely hid the "N Selected"
label behind it while the dropdown was open. Make it transparent/borderless
instead, and hide the label (visibility, not display, so layout doesn't
shift) while open so there's no overlapping text underneath the cursor.

* fix: put the multi-select's search box on its own row instead of overlapping the selection

Overlaying it via position: absolute solved the layout-corruption bug but
traded it for a worse one - it now hid the "N Selected" label completely.
Let it flow normally below the label instead (its own full-width block, small
top margin for separation), matching how a search box should actually behave
- visible on its own line, not fighting the label for the same space. The
container only grows taller while the dropdown is actually open, since the
search row is hidden entirely while closed.

Also reset the stray margin select2 puts on #select2-<id>-container (the
same element as .select2-selection__rendered) - it was inflating the closed
control's height too.

* fix: multi-select search box wraps below label; vertically center label text

The row-direction flex box for select2-selection--multiple had no
flex-wrap, so once the search field was added as a second full-width flex
item its own line-item competed with the label item in the same row -
squeezing/overlapping both instead of the intended label-then-search
stacked layout. flex-wrap: wrap lets the label take row 1 and the
full-width search field naturally wrap to row 2, only while open.

Also give the label an explicit line-height matching the control's height
(same technique already used for the single-select boxes) - flex centering
alone left the text sitting a few pixels high due to the font's default
line-height metrics.

* fix: search select2-multi-count from the dropdown, not an inline box in the control

Root cause of the open-state height jump: select2 gives every multiple-select
a type-to-filter <input> INSIDE the selection control (its SelectionSearch
adapter). When the dropdown opens that input appears, growing the control and
pushing the surrounding filter rows down. No amount of CSS on an in-flow child
of the control avoids that - the search genuinely lives inside the control's
box.

Single-selects don't have this problem because their search lives in the
floating dropdown panel (DropdownSearch adapter), which is body-attached and
never affects page layout. So give the multi-count controls the same wiring:
a custom dropdownAdapter (Dropdown + DropdownSearch + MinimumResultsForSearch
+ AttachBody) that searches from the panel, and a selectionAdapter of just
MultipleSelection + EventRelay with no inline search box at all.

The control now keeps a fixed height whether open or closed, and the surrounding
form no longer shifts. Guarded so it falls back to default behavior if the
select2 AMD modules aren't reachable.

* fix: show 15 select2 dropdown rows before scrolling instead of 20

* fix: actually apply the multi-count dropdown-search adapters + trim control padding

Two bugs behind the "search still looks attached to the (green) control and the
control grows when open" report:

1. The adapters were required with the ARRAY form of select2's amd.require(),
   which almond defers via setTimeout - so multiCountDropdownAdapter/
   multiCountSelectionAdapter were still undefined when the init loop ran, and
   select2 silently fell back to its default inline search box inside the
   control. Fetch each module with the synchronous string form instead so the
   adapters are actually in place at init time. The search now lives in the
   floating dropdown (black), not inline in the green open-state control.

2. Vendor select2.css's .select2-container--default .select2-selection--multiple
   adds padding-bottom:5px (equal specificity, so it wasn't being reset), which
   left the count label sitting against the top of the control with a gap below.
   Set explicit padding: 0 20px 0 0 so the flex-centered label is truly centered
   and the closed control matches the single-select height.

* fix: size select2-multi-count control/dropdown to content, not the native listbox width

* fix: reload graphs after the Template select2 fully closes, not mid-teardown

* docs: track pending select2 upstream dropdown-search PR for future cleanup

* docs: link the actual select2 upstream PR (#6462)

* fix: sync checkbox visuals for graph_template_id's -1/others exclusivity; skip reload if nothing changed

Two bugs in the "All Templates" (-1) mutual-exclusivity sentinel:

1. Selecting -1 didn't visually uncheck the other checkboxes, and vice versa.
   The old code flipped the underlying <option>.selected directly and fired a
   plain 'change' event - but select2's own 'change' listener only refreshes
   the "N Selected" summary label, not the open dropdown's rendered results
   list. The checkbox marks are drawn from aria-selected on those already-
   rendered <li> elements, which never got touched. Fixed by routing through
   select2's own internal event bus (available via $el.data('select2')) with
   'select'/'unselect' triggers instead - the exact same path a real click
   takes, which also runs Results.setClasses() to redraw the checkboxes.

2. Opening the dropdown and closing it again without changing anything used
   to still reload the graphs. Now the selected value is snapshotted on open
   and compared on close; the reload only fires if it actually changed.

* fix: force checkbox refresh in graph_template_id sentinel to fix off-by-one visual lag

Reported symptom: click template T1 while "All Graphs & Templates" is
checked - the "All" box unchecks correctly, but T1 does not visually check.
Click T2 next - T1 (not T2) now shows checked. Click T3 - T2 shows checked.
The checkbox visual was always exactly one click behind the actual selection.

Rather than keep chasing the precise internal event-ordering that causes
this (select2's DataAdapter/Results adapters both listen to the same
internal 'select'/'unselect' events our sentinel re-triggers), force a
direct, unconditional call to the Results adapter's own setClasses() method
(exposed publicly as $el.data('select2').results) immediately after our
selection changes. This guarantees the open dropdown's checkboxes reflect
the current underlying <option> selected state before the handler returns,
independent of dispatch ordering.

* debug: temporary console diagnostics for graphTemplateSentinel checkbox lag

* fix: checkbox checkmark keys off --selected class, not aria-selected (hover was stealing it)

Root cause of the "checkbox always one click behind" bug, finally confirmed
via console diagnostics: our checkbox checkmark CSS keyed off the
aria-selected attribute, but select2's own results.js reuses that exact same
attribute for a second, unrelated purpose - tracking which row is currently
mouse-hovered/keyboard-highlighted (a legitimate ARIA listbox pattern). Every
time the mouse moves onto a new row, select2 clears aria-selected on whatever
row was previously "highlighted" and sets it on the newly-hovered row,
completely independent of actual selection state.

Since a user naturally moves the mouse toward the next item immediately after
clicking the current one, this hover-tracking code was stealing
aria-selected="true" away from the just-selected row and handing it to
whatever's now under the cursor - producing exactly the observed "previous
box shows checked, current one doesn't" pattern. Confirmed via console
logging: aria-selected was momentarily correct right after each click (when
our own forced setClasses() call ran), then immediately got reassigned by the
very next mouseenter as the user moved toward their next target.

Fix: key the checkmark off the select2-results__option--selected CSS class
instead. That class is exclusively added/removed by setClasses() based on the
real underlying <option>.selected state, and is never touched by the
mouseenter/highlight-tracking code - so it can't be hijacked by hovering.

Also removes the temporary console.log diagnostics added while tracking this
down; the forced setClasses() calls in the sentinel are kept as harmless extra
insurance.

* chore: apply php-cs-fixer alignment fixes

CI's php-cs-fixer dry-run flagged 4 files for pure whitespace/alignment
drift introduced by the select2 conversion: array `=>` operators and a
docblock's @param columns need to align to the widest key/name in their
respective blocks, and two inline PHP print tags were missing the space
before `?>`. No behavioral change.

* fix: address Copilot review findings on select2 conversion (develop)

- lib/html_form.php: form_callback()'s select2-callback opt-in path used
  htmle() (double_encode=false) for the select's id/name/class, its
  data-action/data-variables/data-callback attributes, and the
  pre-selected option's value attribute. A pre-encoded entity in any of
  these caller-supplied values would survive into the attribute and be
  decoded back into markup/quotes by the browser. Use html_escape_attr()
  (double_encode=true) for all of these attribute contexts; the option's
  text content stays on htmle() since that's body text, not an
  attribute. Same fix for form_multi_dropdown()'s three
  data-select-all-text/data-select-count-text/data-select-all-value
  attributes.
- lib/html_reports.php: changeTree()'s replaceWith(data) on #branch_id
  only swaps the <select> element itself, leaving a live Select2
  instance's sibling .select2-container orphaned in the DOM. Destroy the
  existing Select2 instance first so the re-initialization a few lines
  down doesn't produce a duplicate branch control.

include/layout.js's select2-callback CSRF finding: verified this is not
an actual bug. select2 4.0.13's AjaxAdapter.query() calls
options.data.call(...) and hands the result straight to $.ajax() via the
default transport; for type: 'post' with a plain-object `data` and no
processData:false/contentType override, jQuery serializes it as the POST
body via $.param(), and Cacti's csrf_check() reads the token via
filter_input(INPUT_POST, ...). The __csrf_magic data() callback already
present here (since the PR's first commit) does get sent correctly. No
global $.fn.select2.defaults override clobbers it. Replied with this
analysis rather than changing working code.

Verified: php -l on both changed PHP files; brace-balance check on
lib/html_reports.php (295 open / 295 close).

* fix: address remaining Copilot review findings on select2 conversion (develop)

- include/layout.js: move the select2-hidden-accessible teardown in
  applySkin() to run before setSelectMenus() instead of after it, so the
  catch-all initializer still re-enhances plain <select> elements that the
  teardown just destroyed (previously they stayed native after any
  post-initial-load applySkin() re-run).
- include/layout.js: namespace the select2-callback widget's
  select2:select listener and unbind it before rebinding, since destroying
  and recreating the Select2 instance on every AJAX filter reload does not
  remove a plain jQuery listener bound to the underlying <select>, which
  was accumulating duplicate invocations of the configured callback.
- include/layout.js, lib/auth.php, lib/html.php: thread $noany/$nonone
  from html_host_filter() through to its ajax-backed select2-callback
  widget (as data-noany/data-nonone attributes forwarded as request vars),
  and have get_allowed_ajax_hosts() honor them. $sql_where is intentionally
  left unapplied over the ajax path and documented as such: passing
  caller-supplied raw SQL across the client/server boundary would be a new
  SQL injection vector, unlike the boolean any/none toggles.
- lib/html.php: use html_escape_attr() instead of htmle() for the two
  attribute contexts in html_host_filter()'s markup (data-callback and the
  option value), since htmle()/html_escape() disables double_encode and so
  a pre-encoded quote entity in the input could survive and be decoded
  back into a live quote by the browser, breaking out of the attribute.
  htmle() is kept for the visible option text.
- lib/html.php: use cacti_js_encode() instead of plain json_encode() for
  the two new multi-select translation strings emitted into the inline
  <script> block, since json_encode() alone does not hex-escape </script>
  sequences.
- lib/html_reports.php: use the global select2SearchRows setting instead
  of a hard-coded 10-row threshold when reinitializing the branch_id
  Select2 control after an AJAX tree change, so the configured
  "Select Search Minimum Rows" setting (including "Always") is respected.
- include/themes/paper-plane/main.css, include/themes/sunrise/main.css:
  fix unreadable dark-on-dark Select2 control text (both the base control
  and its rendered value) by using each theme's existing light foreground
  convention instead of the near-black color left over from the plain
  jQuery UI selectmenu styling.

Verified: tests/Unit/InlineScriptDataEncodingTest,
tests/Unit/Security/Xss/HtmlEscapeTest, and reports/host/graph-filter
related Unit tests all pass; php -l and node --check clean on touched
files; empirically reproduced (jsdom + real select2 4.1.0 + jQuery 4.0)
that the select2-multi-count adapter combination does not create a
duplicate inline search box, contrary to one review comment - no code
change made for that finding, replied with the reproduction instead.
2026-09-23 11:58:48 -04:00
1e5329dd89 security: consolidate verified GHSA-rjvj win32 cmd.exe hardening for develop (#8056)
* fix(win32): harden the cmd.exe shell sinks on develop (GHSA-rjvj-r52f-8v5q)

Forward-port of the 1.2.x win32 command-injection remediation. On Windows the
SNMP/ping/data-query/trap commands run through cmd.exe, which ignores the \"
escape and toggles quoting on every ", so cacti_escapeshellarg cannot stop
& | ^ < > ( ).

- add cacti_escapeshellarg_cmd(), stripping those operators on win32 before
  quoting (a Unix pass-through)
- snmp_escape_string(): strip the operators in the win32 branch
- route the SNMP binary hostname, the data-query device values
  (get_script_query_path), the data-input arg builders, the Windows ping
  hostname, and the SNMP trap notification arguments through the strip

get_script_query_path is device-reachable (a rogue monitored device supplies an
SNMP index/system field that executes on the Windows poller); the rest are
post-auth.

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

* fix(win32): strip %VAR% from the hostname on Windows (GHSA-rjvj-r52f-8v5q)

cmd.exe expands %VAR% even inside quotes, so a device hostname of %USERNAME%
would expand into the SNMP/ping command line. Add an opt-in $strip_env flag to
cacti_escapeshellarg_cmd() and set it for the SNMP binary and ping hostnames,
which never legitimately contain a percent. Community and credential values are
left untouched because they can legitimately contain %.

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

* docs(changelog): note win32 cmd.exe hardening for develop

* security: require Package Management permission to trust new package signers

package_accept_key() (reachable via the repository-based package import flow)
inserted new keys into package_public_keys, the global trust store consulted
by import_validate_signature() for every future import by every user,
without checking for the Package Management (realm 29) permission that
gates package_keys.php/package.php/package_repos.php for the same table.
An Import Templates (realm 17) user could therefore add themselves as a
trusted signer for the whole install. Also add the 'accept' action to the
CSRF-protected bad_actions list so the acceptance step requires POST plus a
valid token like every other state-changing action.

GHSA-gxgm-8rhv-r7jv

* docs(changelog): note package-signer trust authorization fix

* security: close tab-based open redirect bypass in CSRF timeout handler

sanitize_uri()'s guard (20298c6cd) models the WHATWG rule that browsers
strip leading C0 controls/whitespace before resolving a redirect, but
Chromium separately strips an embedded tab from anywhere in the target.
A request target of /%09/evil.example passes the guard (no leading control
char, not two leading slashes) but Chromium reduces the resulting
'Location: /<TAB>/evil.example' header to a network-path reference and
navigates off-site. 1.2.x already uses validate_redirect_url() for this
same sink (3338a63e1), which resolves the URL via parse_url() and checks
host/port against the server-configured values, so it isn't bypassable this
way. Switch develop's CSRF timeout handler to match.

GHSA-c725-4jm5-9fx5

* security: authorize single-item Report actions against ownership

reports.php sets \ = true, so include/auth.php grants an
implicit guest session (when the Guest User feature is enabled) without
ever evaluating the realm 22 gate declared for this page. The single-item
action handlers reports_item_remove()/_moveup()/_movedown() relied solely
on that page-level gate and performed no independent ownership check, so an
unauthenticated guest-bound request could delete or reorder reports_items
rows belonging to any report, including ones owned by an administrator.
reports_form_actions() already gained an equivalent \
check; add the same authorization (Reports admin realm 21, or the
requesting user owns the item's report) to the three single-item handlers.

Same underlying chain as the 1.2.31 fix for reports_user.php (guest bind
removed + cacti_authorize_resource() added to reports_item_remove()); the
rewritten develop reports.php/lib/html_reports.php did not carry either
half of that fix forward.

GHSA-j5jh-fhwg-r56x

* security: authorize tree node CRUD actions against per-tree access

tree.php dispatches create_node/delete_node/move_node/rename_node/copy_node
directly to api_tree_*() with no is_tree_allowed() check, even though the
normal tree-view path already enforces that boundary. A non-admin user with
the Trees realm but no explicit grant on a given tree_id could still create,
delete, move, rename or copy nodes on that tree by calling these actions
directly, bypassing the authorization boundary enforced elsewhere.

GHSA-prxj-84qq-v5jq

* security: remove shell from Realtime RRD create and validate DS bounds

poller_realtime.php built the Realtime-graph RRD 'create' command line
(including the SNMP-sourced ifSpeed/ifHighSpeed interface speed, substituted
in verbatim as the DS maximum) and ran it through shell_exec(). A hostile or
spoofed monitored device could return an ifSpeed value such as
'1000000000\uid=197608(ladams) gid=197121 groups=197121' and get arbitrary OS command execution as the web/poller
user the next time any user with the Realtime Graphs permission (or an
unauthenticated guest, where configured) viewed a realtime graph for an
affected data source.

- lib/rrd.php: rrdtool_function_interface_speed() now casts ifHighSpeed/
  ifSpeed to int before they become the DS maximum.
- lib/rrd.php: rrdtool_function_create() now rejects any non-numeric
  rrd_minimum/rrd_maximum right before they are concatenated onto the
  rrdtool command line, forcing them to 'U' and logging instead.
- poller_realtime.php: the RRD create is now routed through the same
  shell-free rrdtool_execute()/proc_open(bypass_shell) path every other
  RRDtool invocation already uses, matching the pattern 1.2.x already uses
  for this exact call site, instead of shell_exec().

GHSA-f88m-q789-g22j

* security: authorize Report save/edit against report ownership

reports_form_save()'s update path fetched the existing report's owner from
the database but never compared it to the session user before calling
sql_save(), so any user holding only the Reports realm could overwrite
another user's report by POSTing its id. reports_edit() had the same gap on
the read side: it fetched a report by id with no ownership check, so the
same user could view any other user's report configuration (recipients,
schedule, etc.). 1.2.x already guards both call sites with
cacti_authorize_resource(..., 'reports'); develop had lost that check.

GHSA-8f5c-3h59-q7gc

* security: reject ORDER BY sort_column subquery bypass

sanitize_sql_column()'s allowlist regex kept parentheses to support the
INET_ATON(col)/NATURAL_SORT_KEY(col) sort wrappers, but that also let
MySQL subquery syntax through unchanged, since a subquery like
(SELECT(0)FROM(SELECT(SLEEP(5)))a) needs only alphanumerics and
parentheses. get_order_string() then detects the '(' and disables its
backtick quoting, so the payload reaches ORDER BY verbatim, giving any
user with realm 2-5 a blind, time-based SQL injection via sort_column.

sanitize_sql_column() now only allows '(' through when the whole value is
exactly one of the two known safe wrapper forms around a plain identifier;
anything else containing '(' -- including subquery syntax -- now falls
back to the default. Added regression tests covering the reported
subquery and REGEXP-exfiltration payloads.

GHSA-8j73-cvf6-jrvx

* security: confine Reports format_file to a bare filename

reports_form_save() wrote the POST-supplied format_file value verbatim
into reports.format_file, and reports_load_format_file() concatenated it
onto CACTI_PATH_FORMATS with no directory-component check, so a value
such as ../../../../etc/passwd escaped the formats directory and any
Reports-realm user could read arbitrary files readable by the web
server. 1.2.x already fixed the same sink (GHSA-g37j-39f4-6r4j /
CVE-2026-41884, GHSA-mjvw-mhj5-9jcj / CVE-2026-40084); develop had lost
the guard. format_file is now confined to basename() at both the save
site and the load site, with the load site additionally rejecting a
bare '.'/'..'/empty result before any file IO. Added regression tests.

GHSA-5x27-wqpm-m24v

* security: authorize Data Source enable/disable against owning device

ds_enable()/ds_disable() and the bulk drp_action enable/disable loops in
form_actions() passed a user-controlled local_data_id straight into
api_data_source_enable()/api_data_source_disable() with no object-level
authorization check, so any user holding the Sites/Devices/Data realm
could toggle the enabled state of a data source belonging to a device
outside their assigned Device Permissions.

Added data_source_authorized(\), which allows
host-independent data sources (data_local.host_id = 0) and otherwise
defers to is_device_allowed(\), and applied it to both the
single-item and bulk action paths.

GHSA-2fhv-c794-v433

* security: authorize bulk Change Data Source Profile against owning device

The bulk 'Change Data Source Profile' action (drp_action == '6') iterated
selected_items and updated data_template_data/data_template_rrd/poller_item
for each supplied local_data_id with no object-level authorization check,
so any Sites/Devices/Data realm user could retarget the RRD step/heartbeat
of a data source belonging to a device outside their assigned Device
Permissions -- the same class of gap as GHSA-2fhv, in a different bulk
action. Guarded with the data_source_authorized() helper added for that
fix. This action does not exist on 1.2.x (develop-only feature).

GHSA-fc82-w6xq-4jg7

* security: grant AllowOverride All in docker/apache.conf

The packaged docker/apache.conf granted only 'AllowOverride FileInfo
Options=FollowSymLinks' for the Cacti document root. Cacti's shipped
.htaccess deny rules (log/, cache/*, scripts/, cli/, bin/, mibs/, rra/,
contrib/, tests/) rely on 'Require all denied' (Apache 2.4, override
type AuthConfig) and/or legacy 'Order/Deny' (override type Limit),
neither of which FileInfo/Options covers, so Apache silently ignored
every one of those deny rules and served the directories over HTTP to
any unauthenticated client -- most notably log/cacti.log, which can
contain usernames, client IPs, SQL backtraces, and any secrets Cacti
logs (SNMP community strings, LDAP bind details).

AllowOverride All lets every shipped .htaccess apply as intended. Added
a regression test. docker/ does not exist on 1.2.x (develop-only).

GHSA-fxfg-j995-j69j

* docs: add security advisory / CVE policy to Copilot instructions

Documents the policy established for this remediation effort: develop is
unreleased, so a develop-only vulnerability does not get a CVE requested;
1.2.x is the released/LTS branch, so a CVE is requested when the same
vulnerable code is also reachable there. Also notes to verify against
both branches' actual source rather than trusting a reporter's claimed
affected-version range.

* fix(security): update stale test harnesses and address review findings

CI fixes:
- CsrfCheckContractProbe.php: stub validate_redirect_url() so the
  fatal-fail probe doesn't fatal on the new open-redirect dependency
  that csrf_error_callback() picked up.
- InstallerCsrfRecoveryTest.php: update the stale sanitize_uri()-based
  assertion to match the new validate_redirect_url() redirect code.
- psalm-baseline.xml: drop the TaintedHeader suppression for
  include/csrf.php now that validate_redirect_url() resolves it.

Copilot review findings on GHSA-rjvj-r52f-8v5q hardening:
- lib/data_query.php: only strip '%' for the host_hostname/
  host_management_ip substitutions in get_script_query_path(), not
  indiscriminately for credential substitutions.
- lib/functions.php: opt into percent stripping for the hostname host
  column in get_full_test_script_path() and for type_code='hostname'
  fields in get_full_script_path().
- lib/snmpagent.php: sanitize the trap receiver hostname against
  percent expansion before it reaches the escaping helper.
- tests/security/baselines/*.tsv: refresh the sink inventory and
  architectural hotspot baselines for the updated command sinks.
- tests/Unit/DataCollection/Snmp/SnmpCoverageTest.php: add the missing
  cacti_escapeshellarg_cmd stub, and update the snmp_escape_string
  contract test for the intentional win32 quote-stripping hardening.
- tests/Unit/Security/Shell/CmdMetacharHelperTest.php +
  tests/fixtures/CmdMetacharHelperWin32Probe.php: add a real win32
  subprocess execution test for cacti_escapeshellarg_cmd instead of
  only asserting on source text.
- CHANGELOG: add the pull-request reference to the new entry.

* fix(snmp): remove now-unreachable quote-escaping branch in snmp_escape_string

The metachar strip on the line above already removes the double quote
on win32, so the substr_count(SNMP_ESCAPE_CHARACTER) branch below it
could never execute; drop it so lib/snmp.php reaches 100% coverage
again.

* style: fix pre-existing php-cs-fixer blank-line drift blocking CI

data_sources.php and lib/html_reports.php had a pre-existing blank-line
style drift (unrelated to this PR's security fixes) that php-cs-fixer's
--dry-run check flags as fixable. Since "PHP 8.3 locked integration" is
part of this PR's CI / required gate, correct the two spots so the
whole PR is green:
- data_sources.php: collapse a double blank line before form_actions().
- lib/html_reports.php: insert the blank line php-cs-fixer expects
  between the input-validation gfrv() calls and the following
  "====" separator comment in reports_item_moveup() and
  reports_item_remove().

No behavior change; purely CI/style formatting.

* style: refresh phpstan baseline for pre-existing, unrelated findings

Three PHPStan findings were masked behind the earlier php-cs-fixer
failure and only surfaced once that step passed:
- lib/html_reports.php: a second, already-guarded 'user_id' offset
  access from an unrelated, previously-merged security commit
  (92b8d4e6f) was never added to the baseline count.
- cli/splice_rrd.php, poller_realtime.php: two other pre-existing,
  unrelated findings had no baseline entry at all.

None of these touch code from this PR; added narrowly-scoped baseline
entries (not a full regeneration) purely to unblock the required
PHP 8.3 locked integration check.

* fix(security): address follow-up review findings on consolidated PR

Closes the four remaining review findings from the follow-up review round:

- package_import.php: trustSigner() now POSTs with a CSRF token instead of
  $.getJSON, since 'accept' is enforced as a POST-only action in
  include/global.php's bad_actions list (GHSA-gxgm-8rhv-r7jv).

- lib/html_reports.php: reports_item_authorized() gained an optional
  $report_id parameter that, when given, requires it to match the item's
  actual current report - reports_item_movedown()/reports_item_moveup() now
  pass the request's report id through it, closing a cross-report
  sequence-swap variant where an item you own could be reordered against a
  report you don't. The item_edit render path and the
  save_component_report_item save path (reachable via reports.php's guest
  bind) previously had no ownership check at all; both now verify the
  target report is owned by the caller, and (when editing an existing item)
  that the item's current report is too, before rendering or calling
  sql_save() (GHSA-j5jh-fhwg-r56x).

- tree.php + include/global.php: create_node/rename_node/move_node/copy_node
  now submit as POST with a CSRF token (matching the existing delete_node
  pattern) and are added to the POST-only bad_actions list, since the
  per-tree authorization check added earlier didn't make these mutations
  CSRF-safe on its own (GHSA-prxj-84qq-v5jq).

Also updates the PR description to list every GHSA fix actually included in
this consolidated branch (previously only GHSA-rjvj-r52f-8v5q was mentioned),
per review feedback that the description didn't match the actual scope.

* fix: resolve CI failures on PR #8056 (docblock alignment, sink baseline)

- lib/html_reports.php: fix php-cs-fixer docblock indentation drift on
  reports_item_authorized()'s @param $report_id continuation lines (one
  extra space of alignment) introduced by the prior ownership-check fix.
- tests/security/baselines/sink_inventory.baseline.tsv: refresh to track
  the 4 new header('Location: ' . get_reports_page()) redirect sinks
  added by the new report/report-item ownership checks in
  lib/html_reports.php. Verified via
  tests/security/verify_sink_inventory.sh and confirmed
  tests/security/verify_architectural_hotspots.sh still reports no new
  hotspots.

* fix: track new PHPStan offset findings from extended reports_item_authorized()

phpstan-baseline.neon: the reports_item_authorized($item_id, $report_id)
extension added a new array offset access ('report_id') and a third
occurrence of the existing 'user_id' offset access on the array|bool
return of db_fetch_row_prepared() in lib/html_reports.php. Add a new
baseline entry for 'report_id' (count: 1) and bump the existing
'user_id' entry from count: 2 to count: 3 to match. Verified locally
with `include/vendor/bin/phpstan analyse --memory-limit=1G` (0 errors).

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <copilot@users.noreply.github.com>
2026-09-23 09:45:42 -04:00
TheWitness 2e05b98b1f Add select2 for enhanced/searchable dropdowns (#8051)
* feat: add select2 for enhanced/searchable dropdowns

Vendors select2 4.1.0 (MIT, https://select2.org) as a new front-end
dependency, following this branch's existing npm-synced-to-include/js
convention:

- package.json: added "select2": "4.1.0" to dependencies (alongside the
  other npm-tracked front-end libs, so Dependabot can track it).
- build/sync-js.mjs: added node_modules/select2/dist/js/select2.full.js ->
  include/js/select2.js to the asset map (the "full" build bundles its
  jquery-mousewheel dependency, so no extra package is needed).
- include/js/select2.js: the synced output (select2.full.js, unminified,
  matching this repo's "vendor unminified where possible" convention).

CSS, following the existing per-theme vendor-CSS convention (jquery-ui.css,
jquery.multiselect.css, etc. - none of which are npm-synced either):

- include/themes/<theme>/select2.css: the upstream select2.css (structural/
  functional rules), copied unmodified into all 10 themes.
- lib/html.php: wired both the JS and (per-theme) CSS into the page header,
  next to the other jQuery UI plugin includes.
- include/themes/<theme>/main.css: since Select2 4.x publishes no jQuery UI
  theme/adapter, appended a small override block per theme (widget border/
  background/text, hover, and active/highlighted colors) reusing each
  theme's own jquery-ui.css/.ui-state-active colors, so Select2 dropdowns
  match that theme's native jQuery UI look instead of Select2's generic
  default styling.

* feat: initialize select2() widgets in applySkin()

Wires up the select2/select2-nosearch/select2-tags/select2-multi/
select2-multi-tags/select2-callback marker classes so pages can opt in
to a Select2-enhanced dropdown just by adding the class, matching the
existing markup-driven convention used by other jQuery UI widget
setup in this function.

Dialog-hosted selects pass dropdownParent so the dropdown attaches to
the ui-dialog widget instead of escaping under it (a well-known
Select2 + jQuery UI dialog z-index/stacking issue).

* fix: remove accidental theme:'bootstrap' from select2-multi init

* fix: address Copilot review findings on the select2 PR

- package-lock.json: added the select2@4.1.0 entry (registry resolved URL +
  integrity hash) so `npm ci` no longer rejects the package.json/lockfile
  mismatch.
- .gitignore / include/js/select2.js: select2.js is a generated npm-synced
  build output like the other managed libraries, so it belongs in the
  generated-asset ignore list, not committed. Untracked the copy that had
  been committed; `npm run build:js` regenerates it locally.
- build/README.md: documented select2 among the npm-managed libraries, and
  clarified that the per-theme select2.css copies are committed unmodified
  upstream CSS (unlike jquery-ui/jstree's Cacti-owned theme CSS).
- include/themes/paper-plane/main.css: highlighted/focused Select2 options
  were the same white as the dropdown background, giving no visible focus
  indicator; changed to the theme's own hover color (#dadada / #212121).
- include/themes/sunrise/main.css: same issue; changed to this theme's
  established menu focus color (#3C8A3C, matching .ui-menu .ui-state-focus)
  with white text.

* fix: address 3 material Copilot review findings on select2 init

- Exclude the select2/select2-nosearch/select2-tags/select2-multi/
  select2-multi-tags/select2-callback marker classes from
  setSelectMenus()'s generic $('select')... loop, so Select2-marked
  single-selects are no longer first hidden/wrapped by jQuery UI
  Selectmenu before Select2 wraps them again.
- select2-callback: Select2 calls the ajax.url callback with a context
  that is not the original <select>, so $(this).data('callback') read
  undefined inside it. Capture the element's callback URL into a local
  variable before calling .select2() and use that instead.
- select2-callback: it was the only initializer that never checked for a
  jQuery UI dialog ancestor, so its dropdown always appended to body and
  could be hidden behind a dialog's overlay. Set dropdownParent the same
  way the other five initializers do.

* fix: proactively apply 2 material findings from the 1.2.x PR review

Copilot's review of the companion 1.2.x PR (#8052) found 2 bugs in this
same select2-init code that also apply here unchanged:

- Select2 renders its replacement as <span class="select2 select2-container">,
  so the plain class selectors (.select2, .select2-nosearch, etc.) also
  matched Select2's own generated wrappers on every later applySkin() call
  (e.g. during AJAX navigation), re-initializing and nesting wrappers.
  Scoped all six selectors to `select.<marker-class>` so only the original
  <select> elements are ever matched.
- select2-callback's ajax response was never normalized: Cacti's existing
  callback endpoints (e.g. lib/auth.php) return a plain array of
  {label, value, id} objects, but Select2 requires {results: [{id, text}]}.
  Added a processResults mapping so callback-backed dropdowns actually
  populate.
- select2-callback now also sets dropdownParent unconditionally (dialog
  ancestor, or document.body otherwise) instead of leaving it unset outside
  dialogs.
2026-09-22 08:27:00 -04:00
TheWitness 1a37594ad6 Replace unmaintained phpdiff vendor lib with jfcherng/php-diff (develop) (#8050)
* chore: replace unmaintained phpdiff vendor lib with jfcherng/php-diff

The manually-vendored include/vendor/phpdiff (chrisboulton/php-diff v1.1,
2009 - unmaintained since ~2013) backed the package-import file diff
viewer (package_import.php's package_diff_file()), its only call site.

Replace it with jfcherng/php-diff (^6.16, BSD-3-Clause), an actively
maintained continuation of the same library, declared as a real Composer
dependency in composer.json/composer.lock (installed at build/CI time,
same as this branch's other composer.json dependencies - not committed
to include/vendor, unlike the old hand-vendored phpdiff).

package_import.php now uses Jfcherng\Diff\Differ + RendererFactory in
place of the old Diff/Diff_Renderer_Html_Inline classes. Preserved the
existing nosemgrep justification comment, updated to reference the new
library's AbstractHtml::htmlSafe() escaping.

Adopted the vendor's own diff-table.css as the Diff.css in all 10 themes,
replacing the bespoke per-theme stylesheets that were tied to the old
library's class names.

* fix: address review feedback on jfcherng/php-diff migration

- package_import.php: narrow $oldfile/$newfile to array before
  constructing Jfcherng\Diff\Differ so PHPStan can see the
  false branch is excluded (fixes level 6/8 analysis failure).
- tests/tools/check_vendor_policy.php: drop the removed
  include/vendor/phpdiff/ path from the legacy allowlist.
- tests/Unit/Security/Xss/PackageDiffRendererTest.php: rewrite
  against the new Jfcherng\Diff\Differ/RendererFactory API instead
  of the deleted vendored phpdiff classes.
2026-09-21 13:59:50 -04:00
dependabot[bot] 5bbfc23a1d deps(deps-dev): bump the composer group with 3 updates (#8048)
Bumps the composer group with 3 updates: [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer), [overtrue/phplint](https://github.com/overtrue/phplint) and [rector/rector](https://github.com/rectorphp/rector).


Updates `friendsofphp/php-cs-fixer` from 3.95.24 to 3.95.25
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.24...v3.95.25)

Updates `overtrue/phplint` from 9.6.3 to 9.7.2
- [Release notes](https://github.com/overtrue/phplint/releases)
- [Changelog](https://github.com/overtrue/phplint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/overtrue/phplint/compare/9.6.3...9.7.2)

Updates `rector/rector` from 2.6.6 to 2.6.7
- [Release notes](https://github.com/rectorphp/rector/releases)
- [Commits](https://github.com/rectorphp/rector/compare/2.6.6...2.6.7)

---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.25
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: composer
- dependency-name: overtrue/phplint
  dependency-version: 9.7.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: composer
- dependency-name: rector/rector
  dependency-version: 2.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: composer
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 04:02:56 -07:00
TheWitness 170fc72102 Change plugin-enabled log entry from WARNING to NOTE and label CLI-triggered enables (#8045) 2026-09-19 22:02:52 -04:00
TheWitness 22584d45bb chore: bump composer.lock to Pest v3 (already permitted by composer.json) (#8044)
composer.json's require-dev already allows pestphp/pest "^2 || ^3" and
pestphp/pest-plugin-drift "^2.0 || ^3.0", but composer.lock was still
pinned to pest v2.36.1, whose transitive deps (nikic/php-parser v4.19.5,
brianium/paratest v7.4.9) are incompatible with pest v3.

This breaks the plugin-ci-workflow template used by Cacti/plugin_* repos:
their CI does `composer require "pestphp/pest: ^3" ...` as a partial
update, which Composer refuses to resolve because it won't bump other
locked packages (nikic/php-parser, paratest) without --with-all-dependencies,
so every plugin CI run against develop fails at the Install Composer
Dependencies step with "Your requirements could not be resolved".

Ran `composer update pestphp/pest pestphp/pest-plugin-drift
--with-all-dependencies` to bump the lock file to the latest versions
already allowed by composer.json (no composer.json changes needed):
pest v2.36.1 -> v3.8.7, pest-plugin-drift v2.5.0 -> v3.0.0,
phpunit/phpunit v10.5.63 -> v11.5.56, nikic/php-parser v4.19.5 -> v5.9.0,
paratest v7.4.9 -> v7.8.5, plus their transitive deps.

Verified: composer validate --strict passes, composer install from the
updated lock is clean, and re-running the plugin workflow's `composer
require "pestphp/pest: ^3" ...` step against this lock now reports
"Nothing to modify in lock file" instead of a conflict.
2026-09-19 14:27:35 -04:00
TheWitness 344ed3dcef fix(snmpagent): clarify missing SNMP notification receiver message on develop (#8040)
* fix(snmpagent): clarify missing SNMP notification receiver message

* fix(snmpagent): point notice at the real SNMP Managers menu and isolate test config

* fix(js): regenerate DOMPurify 3.4.15 asset and skip node_modules cleanup in CI

* chore: add PR number to CHANGELOG entry
2026-09-18 12:04:32 -04:00
TheWitness 2f14d78927 fix(js): regenerate DOMPurify 3.4.15 asset and keep node_modules during CI (#8043)
* fix(js): regenerate DOMPurify 3.4.15 asset and skip node_modules cleanup in CI

* chore: add PR number to CHANGELOG entry
2026-09-18 11:59:13 -04:00
TheWitness e87d87f0cb fix(cli): replace dead github_actions check with a real TTY check before stty size (#8041)
* fix(cli): detect a real TTY instead of a dead github_actions check before calling stty size

* chore: add CHANGELOG entry for #8041
2026-09-18 11:50:53 -04:00
TheWitness 0c0558f7aa fix: Regression in poller_time table handling (#8036)
* fix: Regressin in poller_time table handling

* fix: Remove Unit test as the logic was incorrect

* fix: PSR on quotes
2026-09-18 06:21:31 -07:00
TheWitness d34fbaacc4 fix: Restore base javascript files for testing (#8037)
* fix: Restore base javascript files for testing

* fix: screenfull 6.0.2 is missing screenfull.js

* fix: Restoring screenful after discovering how the files are managed

* fix: Remove node modules directory after installing modules

* fix: Change in screenfull.js behavior affects cacti

* fix: Restore jQuery 4 compatibility in the tablesorter pager plugin

$.isArray()/$.trim() were removed in jQuery 4.0, which this PR pins in
package.json. The restored jquery.tablesorter.pager.js still called
E.isArray(...)/E.trim(...), so any Ajax pager response threw before
rendering. Also fix a TypeError in the countChildRows branch, which
indexed into `a` while it was still an undeclared var.

Add patches/tablesorter+2.32.0.patch so the fix survives the next
`npm run build:js` sync from node_modules, following the existing
pace-js patch-package precedent.

* fix: Rewrite screenfull's ESM export during sync-js instead of downgrading

screenfull 6+ ships ESM-only (`export default screenfull;` in
node_modules/screenfull/index.js), which throws "export declarations
may only appear at top level of a module" when the copied file is
loaded as a classic <script>.

Add a post-copy transform step to build/sync-js.mjs that rewrites that
line to `window.screenfull = screenfull;` for include/js/screenfull.js,
so the 6.0.2 pin in package.json can be kept instead of reverting to
5.2.0. Throws if the expected export line isn't found, so a future
screenfull release changing its export shape fails the build loudly
rather than silently shipping a broken asset.

Updates tests/js/sync_js.test.mjs to cover the transform and its
failure mode.
2026-09-18 06:16:35 -07:00
dependabot[bot] eb748a151e ci(deps): bump the github-actions group with 3 updates (#8039)
Bumps the github-actions group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

Updates `github/codeql-action/analyze` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

Updates `github/codeql-action/upload-sarif` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-18 04:39:28 -07:00
Thomas VincentandTheWitness cb49f31ff9 Replace csrf-magic with maintained CSRF protection (#7744)
* build: add symfony/security-csrf

Backs the CSRF token core that replaces the vendored csrf-magic fork.

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

* test: fix security-csrf package path assertion

symfony/security-csrf ships CsrfTokenManager.php at the package root, not under src/.

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

* refactor: register the composer autoloader before the CSRF include

CSRF validation ran before the autoloader existed, which blocks any class-based guard.

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

* feat(csrf): add CactiCsrfGuard over symfony/security-csrf

Issues and validates session-scoped tokens; not yet wired into the request path.

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

* feat(csrf): accept pre-upgrade csrf-magic tokens during the grace window

Legacy tokens carry their own expiry, so the fallback closes itself without a config flag.

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

* feat(csrf): port the output buffer rewriter to CactiCsrfGuard

Keeps the hidden field name and JS globals that third-party plugins rely on.

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

* fix(csrf): use json_encode for the JS script context in rewriteBuffer

htmlspecialchars() protects the HTML attribute context but not the
<script> string literal, which relied on Symfony's token generator
never emitting a quote rather than being safe by construction.

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

* refactor(csrf): move the XHR shim to include/js as Cacti-owned code

It has been maintained by the Cacti Group since 2020 and is not an upstream package.

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

* refactor(csrf): retire the vendored csrf-magic fork

The procedural csrf_* surface stays as a deprecated shim so plugins keep working.

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

* feat(csrf): validate requests through CactiCsrfGuard

csrf-magic self-invoked from its own file; the guard is called explicitly.

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

* fix(install): stop gating the permission step on the csrf secret

Retiring csrf-magic removed the directory its default path lives in, and setStep() clamps to the first errored step, so no fresh install could advance.

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

* fix(csrf): key the legacy fallback on the raw secret bytes

refresh_csrf.php writes PHP source with a trailing newline, and trimming that yields a different HMAC key than csrf-magic used.

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

* fix(csrf): log when the guard degrades to disabled in a web context

The installer is the only web path that can reach it, and failing open in silence is not something csrf-magic could do.

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

* docs: changelog for the CSRF library replacement

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

* fix(csrf): mint the token before the session write callback closes it

csrf_startup() minted lazily from the output buffer callback, which runs after session_write_close()'s shutdown-function registration, so a token minted there on a legacy-fallback POST was silently dropped.

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

* fix(csrf): make refresh_csrf.php a no-op

Its default secret path no longer ships and live tokens are session-bound, not HMACs over the secret, so rotation neither worked nor did anything meaningful; running it during the upgrade grace window used to delete the legacy secret and kill validation for pre-upgrade tokens outright.

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

* fix(csrf): fall back to the old default secret path when reading it

csrf-magic tried its own directory as a second candidate regardless of $path_csrf_secret; the new reader only tried the configured path, so a site pointing $path_csrf_secret at a file that was never created lost the legacy-token grace window entirely.

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

* docs(csrf): point the JS header at its BSD license sidecar

The header reduces the original BSD terms to prose and the actual text sits in an unreferenced csrf.js.LICENSE, so add a pointer to it.

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

* fix(csrf): restore csrf_check()'s bool return and $fatal contract

The shim ignored $fatal and always returned void, so audit/audit.php's `!csrf_check(false)` evaluated `!null` (always true) and denied every request instead of getting a real pass/fail result back.

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

* docs: reference the pull request number in the changelog entry

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

* test: resolve includes through Cacti path constants

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

* test(csrf): enforce full coverage

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

* Harden CSRF fallback handling

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

* test(csrf): assert the component stays out of the index

Committing it would leave a second copy to drift against the lock.

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

* ci: align CSRF checks with PHP 8.2

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

* docs: normalize the CSRF changelog entry

* Fix CHANGELOG entry ordering

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

* test(csrf): support constrained test environments

* fix(csrf): narrow nullable guard state

* test: place CSRF coverage in security domain

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

* security(test): match disabled exec exactly

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

* ci: run CSRF coverage on PHP 8.3

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

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-09-18 00:16:39 -04:00
dependabot[bot] cc7d14566c deps(deps): bump the npm group with 2 updates (#8023)
Bumps the npm group with 2 updates: [dompurify](https://github.com/cure53/DOMPurify) and [screenfull](https://github.com/sindresorhus/screenfull).


Updates `dompurify` from 3.4.14 to 3.4.15
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.14...3.4.15)

Updates `screenfull` from 5.2.0 to 6.0.2
- [Release notes](https://github.com/sindresorhus/screenfull/releases)
- [Commits](https://github.com/sindresorhus/screenfull/compare/v5.2.0...v6.0.2)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: screenfull
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-18 00:14:02 -04:00
TheWitness eadbe5a9c6 Device Template Ordering Issues (#8035)
* fix: Regressin in poller_time table handling

* fix: Host Template ordering was broken

* fix: Remove Unit test as the logic was incorrect

* fix: PSR on quotes
2026-09-18 00:13:05 -04:00
Sean Mancini 0468abd92e Add shared PHP coding instructions (#8033) 2026-09-17 16:43:00 -07:00
dependabot[bot] 115073e66e deps(deps): bump the composer group with 3 updates (#8021)
Bumps the composer group with 3 updates: [slim/slim](https://github.com/slimphp/Slim), [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) and [rector/rector](https://github.com/rectorphp/rector).


Updates `slim/slim` from 4.15.2 to 4.15.3
- [Release notes](https://github.com/slimphp/Slim/releases)
- [Changelog](https://github.com/slimphp/Slim/blob/4.x/CHANGELOG.md)
- [Commits](https://github.com/slimphp/Slim/compare/4.15.2...4.15.3)

Updates `friendsofphp/php-cs-fixer` from 3.95.23 to 3.95.24
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.23...v3.95.24)

Updates `rector/rector` from 2.6.5 to 2.6.6
- [Release notes](https://github.com/rectorphp/rector/releases)
- [Commits](https://github.com/rectorphp/rector/compare/2.6.5...2.6.6)

---
updated-dependencies:
- dependency-name: slim/slim
  dependency-version: 4.15.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: composer
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.24
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: composer
- dependency-name: rector/rector
  dependency-version: 2.6.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: composer
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 06:22:52 -07:00
Thomas VincentandTheWitness 157a4458ae fix(auth): reject malformed remember cookies (#7976)
* fix(auth): reject malformed remember cookies

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

* test: load cookie contracts lazily

* test(cookie): fail clearly when contract markers move

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

* fix(auth): align persistent credential lifecycle

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

* test(auth): find cookie function boundaries robustly

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

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-09-12 20:56:24 -04:00
Thomas VincentandTheWitness 055c5ef5fa security: reject LDAP logins that skip a domain bind (#8009)
* hardening: validate the login realm and escape the LDAP username per context

domains_login_process() now rejects a realm get_auth_realms() does not list and
fails closed on one that is listed but has no domain behind it, and the Ldap
class escapes the username for the DN and the search filter separately rather
than relying on a filter-only blocklist.

- an unlisted realm sent with a non-empty password used to match no branch at
  all and returned an empty user with no error, which auth_login.php reads as
  an authenticated user and provisions from the template account

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

* security: reject LDAP logins that skip a domain bind

Posted realms outside user_domains no longer fall through to template or guest login.

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

* test(ldap): align escaping contract with username lookup

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

* test: cover LDAP domain login through OpenLDAP

The e2e stack now binds a real directory user and checks that a forged realm cannot skip that bind.

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

* fix(ldap): address Copilot review feedback

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

* fix: substitute LDAP filter placeholders in one pass

LDAP_ESCAPE_FILTER leaves angle brackets intact, so a looped str_replace can rescan one value as another key.

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

* Change realm retrieval function from gnrv to gfrv

* tests: add gfrv() stub to harnesses that exercise gfrv()-calling code

domains_login_process() and api_aggregate_convert_template() call gfrv() for
realm/aggregate_template_id, but their test harnesses only stubbed gnrv(),
so Pest failed with 'Call to undefined function gfrv()'.

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-09-12 20:49:55 -04:00
Thomas VincentandTheWitness 182b21dc53 fix(csv): align export and graph sample windows (#7987)
* fix(csv): align export and graph sample windows

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

* ci(csv): satisfy release policy checks

* test(csv): align legacy capacity contract

* test(csv): add standard license headers

* fix(rrd): infer step from trailing fetch row

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

* ci(csv): trigger on resolution query coverage

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

* test(rrd): mirror fetch helper arguments

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

* test(rrd): isolate percentile globals

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

* test(rrd): annotate intentional eval

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

* test(rrd): restore integration config

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

* test(rrd): enable strict types

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

* test(rrd): restore font config

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

* ci: align CSV coverage with supported PHP versions

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

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-09-12 20:18:00 -04:00