Files
cacti/tests/e2e/support_lockout_probe.php
Thomas VincentandTheWitness 7c5e4c102a feat(support): consolidate diagnostics, lockout CSRF, process-table hook, and safe-triage (#7514)
* feat(support): add redaction toggle and recent-log panel

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

* test: cover support safe triage

Unit-test the redaction helper, severity log tail and environment table, and add a Docker probe that the redacted report masks the RSA fingerprint and hides the database host and password.

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

* fix(support): escape rsa fingerprint and default unknown poller type

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

* ci: refresh security baselines

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

* fix(support): redact compressed IPv6 addresses

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

* ci: refresh security baselines

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

* ci: skip tests tree in hardening pattern guard

Test fixtures assign request superglobals to exercise product code; the guard only protects product code.

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

* fix(support): drop redundant isset flagged by phpstan

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

* feat(support): guard snmpget path before executing

Reject a null byte and require a regular executable file for path_snmpget
so a misconfigured path cannot throw a ValueError or match a directory.

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

* ci: refresh security baselines for snmpget path guard

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

* fix(support): redact FQDNs before the node name and stop truncating tool output

Masking the node name first could leave a domain suffix unmasked when
the name was a prefix of an FQDN, and strip_tags() on installed SNMP
output dropped legitimate angle-bracket content instead of just
masking it. Also tighten the redact toggle to a strict '1' match,
carry it through page-refresh URLs, and correct the diagnostics report
comment to describe what actually gets masked.

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

* fix(support): guard poller type lookup, log stats, and binary path checks

Fall back to an "Unknown" label when poller_type doesn't match a known
poller instead of an undefined-index warning, suppress warnings from
filesize()/fopen() if the log file disappears between the caller's
check and the open, and require is_file() plus a null-byte guard on
the required-binaries version check (matches the snmpget check next
to it).

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

* docs(tests): align triage probe comments with actual assertions

The header comments claimed the unredacted report exposes the DB host
and password, but the probe only ever asserts the RSA fingerprint;
DB credentials are checked for absence when present, not required to
appear unmasked. Correct the wording in both files.

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

* fix(support): require POST + CSRF token for maintenance lockout, compare-and-set

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

* fix(support): require a valid expected state for lockout compare-and-set

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

* test(security): refresh sink/hotspot baselines for upstream line shifts

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

* fix(support): clamp page offset, quote SQL literals, suppress lockout URL state

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

* test: cover support lockout csrf guard

Exercise the POST-only guard and compare-and-set branches, and add a Docker probe that a GET or tokenless POST cannot change the lockout state.

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

* test(support): guard lockout probe extraction and correct probe scope note

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

* ci: refresh security baselines

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

* ci: skip tests tree in hardening pattern guard

Test fixtures assign request superglobals to exercise product code; the guard only protects product code.

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

* fix(support): keep lockout e2e health poll alive under pipefail

Tolerate grep returning non-zero before the Health field appears so the
poll loop waits instead of exiting the script.

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

* test(support): add integration coverage for the lockout compare-and-set

The Unit test cannot reach the "currently locked" branch (forced reads
return the PHP_TESTING default) and the e2e probe deliberately never
locks the system, so the real read/write round trip against
cacti_lockout_status has no coverage. Add a fast integration test that
drives the lock, a stale-expected rejection, and the unlock against a
real database, skipping gracefully when none is reachable.

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

* test(support): match the page-clamp assertion whitespace-tolerantly

Whitespace-exact substr_count() breaks on reformatting; the regex enforces
the same guard without depending on exact spacing.

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

* fix(support): harden the lockout e2e probe's auth and cookie-jar handling

Assert a CSRF token was extracted and the session actually authenticated
before running the lockout checks, otherwise a failed login still passes.
Also handle tempnam() failure and clean up the cookie jar on every exit
path via a shutdown handler.

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

* feat(support): add diagnostics export and environment checks

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

* test: cover support diagnostics export

Unit-test the environment table and report redaction, and add a Docker probe that checks the report never leaks the database host, password or full RSA fingerprint.

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

* fix(support): guard null snmp version and copy-status styling

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

* ci: refresh security baselines

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

* fix(support): keep diagnostics textarea visible on copy failure and report real extension load state

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

* ci: refresh security baselines

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

* fix(support): verify PHP extensions in both cli and web contexts

show_tech_environment() only checked utility_php_verify_extensions()'s
web flag, which seeds CLI-only extensions like pcntl as satisfied
without checking whether they're actually loaded. Shell out to
cli_check.php for the real CLI state and combine it with the web
state the same way lib/installer.php does, so status reflects reality
in both directions.

Also guard the source-extraction helpers in SupportDiagnosticsTest and
the e2e probe against a moved/unreadable support.php instead of
letting strpos(false, ...)/eval() fail confusingly.

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

* fix(support): require POST + CSRF token for maintenance lockout, compare-and-set

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

* refactor(support): register process tables via support_process_tables hook

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

* fix(support): guard non-array hook result and empty process-table set

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

* fix(support): cast poller_interval to int in SQL and validate hook entry types

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

* test(security): refresh sink/hotspot baselines for upstream line shifts

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

* fix(support): reuse forced admin lookup and coerce expected state

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

* test: cover support process-table hook

Verify the hook drops malformed plugin definitions and keeps valid ones, and add a Docker probe that the hook returns well-formed tables against a real database.

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

* test(support): guard hook extraction and tolerate empty health probe

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

* ci: refresh security baselines

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

* fix(support): escape plugin-contributed process-table cells

Plugins can now supply the process-table SELECT fragments via the
support_process_tables hook, so escape the rendered cell values, and
install the e2e cleanup trap before backing up config.php.

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

* ci: refresh security baselines for support.php line shift

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

* fix(support): use form_selectable_ecell for process rows and cast pid in row id

Plugin-contributed process-table values were only escaped via manual
html_escape() calls, and the row id was built from an unsanitized pid;
switch to the file's existing form_selectable_ecell() helper and cast
pid to int before it goes into the id attribute.

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

* test(support): harden hook-call rewrite and narrow validation comment

Replace the exact-string str_replace() with a regex plus a match-count
assertion so the probe fails loudly instead of silently no-op'ing if
support_process_tables() formatting drifts, and correct the header
comment's claim that malformed definitions can't corrupt the UNION --
the validation only checks types, not SQL validity.

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

* test(support): make e2e hook extraction regex whitespace-tolerant

The extraction pattern required exact spacing around the function
signature; match flexible whitespace instead and fail loudly if
preg_match doesn't return exactly one match.

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

* test(support): add integration coverage for the process tables SQL path

Unit tests eval() support_process_tables() with the plugin hook stubbed
and never touch a database; the e2e Docker probe calls the same function
directly but never builds or executes the UNION show_cacti_processes()
runs. Add a fast, DB-only integration test that executes the real query
against a live schema, skipping gracefully when no database is reachable.

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

* fix(support): harden process table hook contracts

* docs: add changelog entry

* style: satisfy support hook fixer rules

* fix(support): reconcile escaping contract and diagnostics test after merge

tech_env_status() now escapes its $text argument (safe-triage's design,
tested by SupportSafeTriageTest); strip the now-redundant html_escape()
calls at its call sites in show_tech_environment() to avoid double
encoding. Update SupportDiagnosticsTest's source-extraction marker and
eval() inputs to match the merged, redact-toggle-aware report block from
safe-triage. Refresh the sink inventory baseline for the resulting line
shift.

* fix(support): escape the PHP uname value on both redact paths

Hostnames and kernel strings can carry markup, and support_redact() itself substitutes <host>, so redact first and escape the result either way.

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

* test(support): locate the diagnostics report block by regex, not literal

The $snmp_line assignment became a conditional and the literal start marker stopped matching, aborting the probe before it built a report.

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

* docs: append the process tables entry to the feature block

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

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-08-05 10:42:46 -04:00

134 lines
4.8 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* Runs inside the Cacti web container. Verifies the maintenance-lockout CSRF/POST
* guard (support.php, #7352) over real HTTP: a GET to action=lockout, and a POST
* without a valid __csrf_magic token, must both be refused without changing
* cacti_lockout_status. The probe asserts only these refusal cases; it does not
* perform a real toggle, so it never leaves the system in a locked-out state.
*
* Usage (inside the container):
* BASE_URL=http://localhost/cacti php support_lockout_probe.php
*/
chdir(dirname(__DIR__, 2));
require_once __DIR__ . '/../../include/global.php';
$base = rtrim(getenv('BASE_URL') ?: 'http://localhost/cacti', '/');
$user = getenv('CACTI_USER') ?: 'admin';
$pass = getenv('CACTI_PASS') ?: 'admin';
$jar = tempnam(sys_get_temp_dir(), 'cacticookie');
if ($jar === false) {
fwrite(STDERR, "FAIL: could not create a cookie-jar temp file\n");
exit(1);
}
// Runs on every exit path, including lockout_probe_fail()'s exit(1), so the
// jar never lingers in the temp directory after a failed run.
register_shutdown_function(static function () use ($jar): void {
@unlink($jar);
});
function lockout_probe_fail(string $message): void {
fwrite(STDERR, "FAIL: $message\n");
exit(1);
}
function lockout_probe_request(string $url, string $jar, ?array $post = null): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_COOKIEJAR => $jar,
CURLOPT_COOKIEFILE => $jar,
CURLOPT_TIMEOUT => 15,
]);
if ($post !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post));
}
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [(int) $code, (string) $body];
}
function lockout_probe_csrf(string $html): string {
// csrf-magic embeds the token in a hidden field and a JS variable.
if (preg_match('/name="__csrf_magic"\s+value="([^"]+)"/', $html, $m)) {
return $m[1];
}
if (preg_match("/csrfMagicToken\s*=\s*\"([^\"]+)\"/", $html, $m)) {
return $m[1];
}
return '';
}
// Authenticate.
[, $loginHtml] = lockout_probe_request("$base/index.php", $jar);
$token = lockout_probe_csrf($loginHtml);
if ($token === '') {
lockout_probe_fail('could not extract a CSRF token from the login page');
}
lockout_probe_request("$base/index.php", $jar, [
'__csrf_magic' => $token,
'action' => 'login',
'login_username' => $user,
'login_password' => $pass,
]);
// Without this, a failed login (bad credentials, CSRF rejected) would still
// "pass" below: the lockout requests would just bounce off auth instead of
// the guard under test, leaving cacti_lockout_status untouched either way.
[, $sessionHtml] = lockout_probe_request("$base/index.php", $jar);
if (str_contains($sessionHtml, 'name="login_username"')) {
lockout_probe_fail('login did not authenticate the session (login form still shown)');
}
$before = read_config_option('cacti_lockout_status', true);
// A GET must be refused: the guard redirects before any state change.
lockout_probe_request("$base/support.php?action=lockout", $jar);
$after_get = read_config_option('cacti_lockout_status', true);
if ($after_get !== $before) {
lockout_probe_fail('GET to action=lockout changed the lockout state');
}
// A POST without the CSRF token must be refused by csrf_check().
lockout_probe_request("$base/support.php?action=lockout", $jar, ['expected' => 'unlocked']);
$after_post = read_config_option('cacti_lockout_status', true);
if ($after_post !== $before) {
lockout_probe_fail('POST without a CSRF token changed the lockout state');
}
print "PASS support lockout csrf docker probe\n";