diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 5e645dcbb..6f5ac6f2f 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -70,6 +70,7 @@
- Escape output for HTML attribute contexts with `html_escape_attr()`, not `htmle()`/`html_escape()`; only the attribute helper double-encodes to stop pre-encoded entities from breaking out of the attribute.
## Workflows you’ll actually use
+- **Windows/WSL**: When working in VS Code from a Windows machine, run all fixes, builds, linters, tests, and git operations inside WSL (a Linux distro) rather than native Windows. The toolchain (PHP 8.3, Composer, `php-cs-fixer`, Pest) and the repo's tab indentation / `\n` line-ending conventions are Linux-first; running them on native Windows produces spurious diffs and failures. Edit the WSL-mounted checkout (e.g. under `/mnt/c/...` or a native WSL path) and invoke `composer` scripts such as `composer php-cs-fixit` from the WSL shell.
- Install deps: `composer install` (CI validates via `.github/workflows/syntax.yml`).
- Install/upgrade DB: `php -q cli/install_cacti.php --accept-eula --install --force` and `php -q cli/upgrade_database.php --forcever=$(cat include/cacti_version)`.
- Run poller: `php poller.php --poller=1 --force --debug` (daemon debug: `./cactid.php --foreground --debug`).
diff --git a/CHANGELOG b/CHANGELOG
index f45428c6a..a0c435c12 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -5,6 +5,26 @@ Cacti CHANGELOG
-security#GHSA-m6wx-f538-m6q3: Apply the .log extension whitelist to path_boost_log to block log-poisoning RCE
-security#GHSA-73qf-hq23-c4m7: Authorize tree lock, unlock, and get_node against per-tree access before dispatch
-security#GHSA-967c-6qj7-q7rh: Confirm the rrdtool control-character strip closes the RRD-create |query_*| injection path
+-security#GHSA-f7jw-gfhr-cxwm: Escape the automation tree-rule preview Resulting Branch values
+-security#GHSA-rp5g-r5vp-q7j6: Quote the db_column_exists() LIKE term so the SQLi-defense helper is not itself injectable
+-security#GHSA-vx2m-q53g-hmrf: Escape host_snmp_cache.field_name in the automation pivot CASE WHEN builders
+-security#GHSA-h5wg-qf6f-7f6r: Confine plugin DDL table identifiers in api_plugin_drop_table and api_plugin_db_table_create
+-security#GHSA-929h-4c93-hxcg: Quote the aggregate_make_sql_where() LIKE filter term
+-security#GHSA-mfw8-r3jx-67ff: Use db_qstr() instead of addslashes() for the cli add_graphs REGEXP clause
+-security#GHSA-m49v-hr7h-wwcj: Restrict the user_admin sort_column to displayed columns so ORDER BY cannot pivot onto user_auth secrets
+-security#GHSA-mpfm-qhvj-5879: Validate the theme in rrdtool_create_error_image() through the theme allowlist
+-security#GHSA-m3fh-gxqj-76hq: Strip control characters from the SNMPv3 credential device fields
+-security#GHSA-cg45-2mww-g98p: Revoke the server-side remember-me row on the auth_changepassword nopassword path
+-security#GHSA-xq26-f282-f998: Enforce a retention window on the remember-me cookie token check
+-security#GHSA-m67r-fcmw-gvv7: Confine the imported data-query xml_path to the Cacti tree
+-security#GHSA-4gph-8x7q-h365: Confirm the report-add device description status message is escaped (regression test)
+-security#GHSA-977w-79m7-xjc4: Confirm the graph export legend columns are html-escaped (regression test)
+-security#GHSA-mgcq-r6vm-4xhw: Confirm the Reports Data Query name is html-escaped (regression test)
+-security#GHSA-5p79-q5q3-84vw: Confirm die_html_input_error escapes the echoed variable and value (regression test)
+-security#GHSA-cfhh-pwvx-gp5g: Confirm the aggregate rfilter is no longer echoed into a raw HTML attribute (regression test)
+-security#GHSA-wfcf-2x67-2jxg: Confirm the user_admin group filter is a bound parameter (regression test)
+-security#GHSA-pjq5-3268-g222: Confirm path_dsstats_log is escaped before the dsstats shell redirect (regression test)
+-security#GHSA-wp33-g6gc-444m: Confirm the data_templates rrd_maximum/rrd_minimum validator is anchored (regression test)
-security#GHSA-69vw-qxxv-p8w2: Stop auto-granting an admin session when auth_method is None and fail closed pending a manual reset
-security#GHSA-r7hc-hrpv-52gf: Validate host_id before it is concatenated into the graph_view list and preview filter WHERE clauses (#8100) - Special Thanks to Jiecub3, Aur0ra-m and lz2y
-security#GHSA-6mjm-5vwr-3fr2: Validate and bind local_graph_id in the aggregate graph item sequence query (#8099) - Special Thanks to Jiecub3, Aur0ra-m and lz2y
diff --git a/aggregate_graphs.php b/aggregate_graphs.php
index b0ec44c2c..6c0c2c178 100644
--- a/aggregate_graphs.php
+++ b/aggregate_graphs.php
@@ -1839,7 +1839,7 @@ function aggregate_make_sql_where(string $sql_where, array $items, string $field
} elseif (cacti_strtolower($i) == 'or') {
$sql_where .= ' OR ';
} else {
- $sql_where .= ($termcount > 0 ? ' OR ' : '') . $field . " LIKE '%" . trim($i) . "%'";
+ $sql_where .= ($termcount > 0 ? ' OR ' : '') . $field . ' LIKE ' . db_qstr('%' . trim($i) . '%');
$termcount++;
}
}
diff --git a/auth_changepassword.php b/auth_changepassword.php
index 1a132f83f..ce54727f5 100644
--- a/auth_changepassword.php
+++ b/auth_changepassword.php
@@ -98,6 +98,8 @@ if ($user['password_change'] != 'on') {
// destroy session information
kill_session_var(SESS_USER_ID);
+ // GHSA-cg45-2mww-g98p: revoke the server-side remember-me row too, not just the browser cookie.
+ clear_auth_cookie();
cacti_cookie_logout();
$return = validate_redirect_url($_SERVER['HTTP_REFERER'] ?? '', 'index.php');
diff --git a/cli/add_graphs.php b/cli/add_graphs.php
index 759538ad0..d5e92917b 100755
--- a/cli/add_graphs.php
+++ b/cli/add_graphs.php
@@ -741,7 +741,7 @@ if (cacti_sizeof($parms)) {
if (isset($dsGraph['snmpValue'][$index_snmp_filter])) {
$req .= ' AND field_value = ' . db_qstr($dsGraph['snmpValue'][$index_snmp_filter]) . ')';
} elseif (isset($dsGraph['snmpValueRegex'][$index_snmp_filter])) {
- $req .= ' AND field_value REGEXP "' . addslashes($dsGraph['snmpValueRegex'][$index_snmp_filter]) . '")';
+ $req .= ' AND field_value REGEXP ' . db_qstr($dsGraph['snmpValueRegex'][$index_snmp_filter]) . ')';
}
$index_snmp_filter++;
@@ -754,7 +754,7 @@ if (cacti_sizeof($parms)) {
foreach ($dsGraph['snmpFieldExclude'] as $snmpField) {
$req .= ' AND snmp_index NOT IN (
SELECT DISTINCT snmp_index FROM host_snmp_cache WHERE host_id=' . $host_id . ' AND field_name = ' . db_qstr($snmpField);
- $req .= ' AND field_value REGEXP "' . addslashes($dsGraph['snmpValueExclude'][$index_snmp_filter]) . '")';
+ $req .= ' AND field_value REGEXP ' . db_qstr($dsGraph['snmpValueExclude'][$index_snmp_filter]) . ')';
$index_snmp_filter++;
}
}
diff --git a/lib/api_automation.php b/lib/api_automation.php
index 7e7d875da..e280697f5 100644
--- a/lib/api_automation.php
+++ b/lib/api_automation.php
@@ -1271,9 +1271,10 @@ function display_matching_trees(int $rule_id, int $rule_type, array $item, strin
for ($j = 0; cacti_sizeof($replacement); $j++) {
if ($j > 0) {
$repl .= '
';
- $repl .= str_pad('', $j * 3, '-') . ' ' . array_shift($replacement);
+ // GHSA-f7jw-gfhr-cxwm: the replacement values carry attacker-influenced device/data fields; escape them while keeping the
layout.
+ $repl .= str_pad('', $j * 3, '-') . ' ' . htmle((string) array_shift($replacement));
} else {
- $repl = array_shift($replacement);
+ $repl = htmle((string) array_shift($replacement));
}
}
@@ -1883,7 +1884,7 @@ function build_data_query_sql(array $rule) : string {
if (cacti_sizeof($field_names) > 0) {
foreach (($field_names ?: []) as $column) {
$field_name = $column['field_name'];
- $sql_query .= ",\n\tMAX(CASE WHEN field_name='$field_name' THEN field_value ELSE NULL END) AS '$field_name'";
+ $sql_query .= ",\n\tMAX(CASE WHEN field_name = " . db_qstr($field_name) . ' THEN field_value ELSE NULL END) AS ' . db_qstr($field_name);
}
}
@@ -2273,7 +2274,7 @@ function make_host_snnp_cache_sql() : string|false {
$sql = "\t\tSELECT host_id ";
foreach ($fields as $field) {
- $sql .= ",\n\t\t\tMAX(CASE WHEN field_name = '{$field['field_name']}' THEN field_value ELSE NULL END) AS `{$field['field_name']}`";
+ $sql .= ",\n\t\t\tMAX(CASE WHEN field_name = " . db_qstr($field['field_name']) . ' THEN field_value ELSE NULL END) AS ' . db_qstr($field['field_name']);
}
$sql .= "\n\t\t\tFROM host_snmp_cache AS hsc GROUP BY host_id";
@@ -3223,7 +3224,7 @@ function create_dq_graphs(int $host_id, int $snmp_query_id, array $rule) : bool
if (cacti_sizeof($field_names) > 0) {
foreach ($field_names as $column) {
- $sql_query .= ", MAX(CASE WHEN field_name ='$column' THEN field_value ELSE NULL END) AS '$column'";
+ $sql_query .= ', MAX(CASE WHEN field_name = ' . db_qstr($column) . ' THEN field_value ELSE NULL END) AS ' . db_qstr($column);
$i++;
}
}
diff --git a/lib/api_device.php b/lib/api_device.php
index 8533c55a4..1f73229e1 100644
--- a/lib/api_device.php
+++ b/lib/api_device.php
@@ -1100,16 +1100,17 @@ function api_device_save(int $id, int $device_template_id, string $description,
$save['snmp_options'] = form_input_validate($snmp_options, 'snmp_options', '^[0-9]+$', false, 3);
$save['snmp_version'] = form_input_validate($snmp_version, 'snmp_version', '', true, 3);
- $save['snmp_community'] = form_input_validate($snmp_community, 'snmp_community', '', true, 3);
+ // GHSA-m3fh-gxqj-76hq: SNMP credentials reach the net-snmp command line; strip control characters the same way snmp_community's sibling requires.
+ $save['snmp_community'] = preg_replace('/[\x00-\x1f\x7f]/', '', (string) form_input_validate($snmp_community, 'snmp_community', '', true, 3));
if ($save['snmp_version'] == 3) {
- $save['snmp_username'] = form_input_validate($snmp_username, 'snmp_username', '', true, 3);
- $save['snmp_password'] = form_input_validate($snmp_password, 'snmp_password', '', true, 3);
+ $save['snmp_username'] = preg_replace('/[\x00-\x1f\x7f]/', '', (string) form_input_validate($snmp_username, 'snmp_username', '', true, 3));
+ $save['snmp_password'] = preg_replace('/[\x00-\x1f\x7f]/', '', (string) form_input_validate($snmp_password, 'snmp_password', '', true, 3));
$save['snmp_auth_protocol'] = form_input_validate($snmp_auth_protocol, 'snmp_auth_protocol', "^(\[None\]|MD5|SHA|SHA224|SHA256|SHA384|SHA512)$", true, 3);
- $save['snmp_priv_passphrase'] = form_input_validate($snmp_priv_passphrase, 'snmp_priv_passphrase', '', true, 3);
+ $save['snmp_priv_passphrase'] = preg_replace('/[\x00-\x1f\x7f]/', '', (string) form_input_validate($snmp_priv_passphrase, 'snmp_priv_passphrase', '', true, 3));
$save['snmp_priv_protocol'] = form_input_validate($snmp_priv_protocol, 'snmp_priv_protocol', "^(\[None\]|DES|AES|AES128|AES192|AES192C|AES256|AES256C)$", true, 3);
- $save['snmp_context'] = form_input_validate($snmp_context, 'snmp_context', '', true, 3);
- $save['snmp_engine_id'] = form_input_validate($snmp_engine_id, 'snmp_engine_id', '', true, 3);
+ $save['snmp_context'] = preg_replace('/[\x00-\x1f\x7f]/', '', (string) form_input_validate($snmp_context, 'snmp_context', '', true, 3));
+ $save['snmp_engine_id'] = preg_replace('/[\x00-\x1f\x7f]/', '', (string) form_input_validate($snmp_engine_id, 'snmp_engine_id', '', true, 3));
if (strlen($save['snmp_password']) < 8 && $snmp_auth_protocol != '[None]') {
raise_message(32);
diff --git a/lib/auth.php b/lib/auth.php
index 6fc9c2012..3cb1cd450 100644
--- a/lib/auth.php
+++ b/lib/auth.php
@@ -170,10 +170,12 @@ function check_auth_cookie() : int|false {
if (cacti_sizeof($user_info)) {
$secret = hash('sha512', $token, false);
+ // GHSA-xq26-f282-f998: reject remember-me tokens past the 90-day retention window instead of trusting the cron purge alone.
$found = db_fetch_cell_prepared('SELECT user_id
FROM user_auth_cache
WHERE user_id = ?
- AND token = ?',
+ AND token = ?
+ AND last_update >= DATE_SUB(NOW(), INTERVAL 90 DAY)',
[$user_info['id'], $secret]
);
diff --git a/lib/database.php b/lib/database.php
index caf4db041..59408f493 100644
--- a/lib/database.php
+++ b/lib/database.php
@@ -1496,7 +1496,8 @@ function db_column_exists(string $table, string $column, bool $log = true, mixed
return $results[$index][$table][$column];
}
- $results[$index][$table][$column] = (db_fetch_cell("SHOW columns FROM `$table` LIKE '$column'", '', $log, $db_conn) ? true : false);
+ // GHSA-rp5g-r5vp-q7j6: this helper validates attacker-supplied identifiers, so its own LIKE term must be quoted on the same connection that runs the query.
+ $results[$index][$table][$column] = (db_fetch_cell("SHOW columns FROM `$table` LIKE " . db_qstr($column, $db_conn), '', $log, $db_conn) ? true : false);
return $results[$index][$table][$column];
}
diff --git a/lib/import.php b/lib/import.php
index 0245324de..066c5cbc8 100644
--- a/lib/import.php
+++ b/lib/import.php
@@ -1693,14 +1693,33 @@ function xml_to_data_query(string $hash, array &$xml_array, array &$hash_cache,
}
if (isset($save['xml_path'])) {
- $path = str_replace('', CACTI_PATH_BASE, $save['xml_path']);
+ $path = str_replace('', CACTI_PATH_BASE, (string) $save['xml_path']);
- if (!file_exists($path)) {
+ // GHSA-m67r-fcmw-gvv7: confine the imported path to the Cacti tree so it cannot probe arbitrary files.
+ // Resolve only the trusted base; a lexical prefix/traversal check gates the attacker value so
+ // realpath() never touches a UNC/remote path (which on Windows would initiate SMB access) before containment is proven.
+ $allowed_base = realpath(CACTI_PATH_BASE);
+ $base_norm = ($allowed_base !== false) ? str_replace('\\', '/', $allowed_base) : false;
+ $path_norm = str_replace('\\', '/', $path);
+
+ $lexically_contained = ($base_norm !== false
+ && strpos($path_norm, $base_norm . '/') === 0
+ && !str_contains($path_norm, '/../')
+ && !str_ends_with($path_norm, '/..'));
+
+ if (!$lexically_contained) {
$files[$path] = 'missing';
- } elseif (!is_readable($path)) {
- $files[$path] = 'notreadable';
} else {
- $files[$path] = 'found';
+ // Candidate is lexically inside the tree; canonicalize now to catch symlink escapes.
+ $resolved = realpath($path);
+
+ if ($resolved === false || strpos(str_replace('\\', '/', $resolved) . '/', $base_norm . '/') !== 0) {
+ $files[$path] = 'missing';
+ } elseif (!is_readable($resolved)) {
+ $files[$path] = 'notreadable';
+ } else {
+ $files[$path] = 'found';
+ }
}
}
diff --git a/lib/plugins.php b/lib/plugins.php
index 06bbcd550..4a73cba91 100644
--- a/lib/plugins.php
+++ b/lib/plugins.php
@@ -462,6 +462,13 @@ function api_plugin_status_run(string $hook, array $required_capabilities, strin
function api_plugin_db_table_create(string $plugin, string $table, array $data) : void {
include_once(CACTI_PATH_LIBRARY . '/database.php');
+ // GHSA-h5wg-qf6f-7f6r: reject an unsafe DDL identifier outright; normalizing it could silently collide with an unrelated table.
+ if ($table === '' || !preg_match('/^[a-zA-Z0-9_]+$/', $table)) {
+ cacti_log("ERROR: Refusing to create plugin table with invalid identifier '$table'", false, 'PLUGIN');
+
+ return;
+ }
+
$result = db_fetch_assoc('SHOW TABLES');
$tables = [];
@@ -572,7 +579,14 @@ function api_plugin_db_table_create(string $plugin, string $table, array $data)
}
function api_plugin_drop_table(string $table) : void {
- db_execute("DROP TABLE IF EXISTS $table");
+ // GHSA-h5wg-qf6f-7f6r: reject an unsafe identifier outright so a crafted name cannot extend the DROP across other tables or collide after normalization.
+ if ($table === '' || !preg_match('/^[a-zA-Z0-9_]+$/', $table)) {
+ cacti_log("ERROR: Refusing to drop plugin table with invalid identifier '$table'", false, 'PLUGIN');
+
+ return;
+ }
+
+ db_execute('DROP TABLE IF EXISTS `' . $table . '`');
api_plugin_drop_remote_table($table);
}
@@ -1030,6 +1044,13 @@ function api_plugin_replicate_config() : void {
function api_plugin_drop_remote_table(string $table) : void {
include_once(CACTI_PATH_LIBRARY . '/poller.php');
+ // GHSA-h5wg-qf6f-7f6r: self-protect so no caller can push an unsafe identifier to the remote pollers.
+ if ($table === '' || !preg_match('/^[a-zA-Z0-9_]+$/', $table)) {
+ cacti_log("ERROR: Refusing to drop remote plugin table with invalid identifier '$table'", false, 'PLUGIN');
+
+ return;
+ }
+
$gone_time = read_config_option('poller_interval') * 2;
$pollers = array_rekey(
@@ -1047,7 +1068,7 @@ function api_plugin_drop_remote_table(string $table) : void {
$rcnn_id = poller_connect_to_remote($poller_id);
if ($rcnn_id !== false) {
- db_execute("DROP TABLE IF EXISTS $table", false, $rcnn_id);
+ db_execute('DROP TABLE IF EXISTS `' . $table . '`', false, $rcnn_id);
}
}
}
diff --git a/lib/rrd.php b/lib/rrd.php
index d1b955826..9bca37010 100644
--- a/lib/rrd.php
+++ b/lib/rrd.php
@@ -5580,7 +5580,8 @@ function rrdtool_create_error_image(string $string, mixed $width = '', mixed $he
$font_file = 'C:/Windows/Fonts/Arial.ttf';
}
- $themefile = CACTI_PATH_INCLUDE . '/themes/' . get_selected_theme() . '/rrdtheme.php';
+ // GHSA-mpfm-qhvj-5879: get_selected_theme() bypasses the theme allowlist, so validate it before it builds an include path.
+ $themefile = CACTI_PATH_INCLUDE . '/themes/' . cacti_validate_theme(get_selected_theme()) . '/rrdtheme.php';
if (file_exists($themefile) && is_readable($themefile)) {
include($themefile);
diff --git a/tests/Unit/Security/Auth/RememberMeSessionHardeningTest.php b/tests/Unit/Security/Auth/RememberMeSessionHardeningTest.php
new file mode 100644
index 000000000..5b4059199
--- /dev/null
+++ b/tests/Unit/Security/Auth/RememberMeSessionHardeningTest.php
@@ -0,0 +1,25 @@
+toContain('clear_auth_cookie()');
+});
+
+test('remember-me token check enforces a retention window (GHSA-xq26)', function () use ($root) {
+ $s = file_get_contents($root . '/lib/auth.php');
+ expect($s)->toContain('AND last_update >= DATE_SUB(NOW(), INTERVAL 90 DAY)');
+});
diff --git a/tests/Unit/Security/PathTraversal/PathAndRrdHardeningTest.php b/tests/Unit/Security/PathTraversal/PathAndRrdHardeningTest.php
new file mode 100644
index 000000000..6620369e8
--- /dev/null
+++ b/tests/Unit/Security/PathTraversal/PathAndRrdHardeningTest.php
@@ -0,0 +1,38 @@
+toContain('realpath(CACTI_PATH_BASE)')
+ ->and($s)->toContain('$lexically_contained');
+});
+
+test('rrdtool_create_error_image validates the theme (GHSA-mpfm)', function () use ($root) {
+ $s = file_get_contents($root . '/lib/rrd.php');
+ expect($s)->toContain('cacti_validate_theme(get_selected_theme())');
+});
+
+test('path_dsstats_log is escaped before the shell redirect (GHSA-pjq5)', function () use ($root) {
+ $s = file_get_contents($root . '/lib/dsstats.php');
+ expect($s)->toContain(">> ' . cacti_escapeshellarg");
+});
+
+test('data_templates rrd_maximum/rrd_minimum validator is anchored (GHSA-wp33)', function () use ($root) {
+ $s = file_get_contents($root . '/data_templates.php');
+ expect($s)->toContain("'rrd_maximum', [new Assert")
+ ->and($s)->toContain('\z/');
+});
diff --git a/tests/Unit/Security/Shell/SnmpControlCharacterValidationTest.php b/tests/Unit/Security/Shell/SnmpControlCharacterValidationTest.php
new file mode 100644
index 000000000..700fac7e8
--- /dev/null
+++ b/tests/Unit/Security/Shell/SnmpControlCharacterValidationTest.php
@@ -0,0 +1,22 @@
+toContain('(string) form_input_validate($snmp_password')
+ ->and($s)->toContain('(string) form_input_validate($snmp_priv_passphrase')
+ ->and($s)->toContain('(string) form_input_validate($snmp_community');
+});
diff --git a/tests/Unit/Security/SqlInjection/SqlInjectionHardeningRegressionTest.php b/tests/Unit/Security/SqlInjection/SqlInjectionHardeningRegressionTest.php
new file mode 100644
index 000000000..1ea379514
--- /dev/null
+++ b/tests/Unit/Security/SqlInjection/SqlInjectionHardeningRegressionTest.php
@@ -0,0 +1,62 @@
+toContain('db_qstr($column, $db_conn)')
+ ->and($s)->not->toContain('LIKE \'$column\'');
+});
+
+test('automation pivot builders escape field_name (GHSA-vx2m)', function () use ($root) {
+ $s = file_get_contents($root . '/lib/api_automation.php');
+ // The leading literal's quote style varies (strings with \n\t stay double-quoted, others are single),
+ // so count both forms of the db_qstr()-escaped pivot.
+ $escaped = substr_count($s, 'CASE WHEN field_name = " . db_qstr(')
+ + substr_count($s, "CASE WHEN field_name = ' . db_qstr(");
+
+ expect($s)->not->toContain("CASE WHEN field_name='")
+ ->and($s)->not->toContain("CASE WHEN field_name ='")
+ ->and($escaped)->toBeGreaterThanOrEqual(3);
+});
+
+test('aggregate LIKE filter term is quoted (GHSA-929h)', function () use ($root) {
+ $s = file_get_contents($root . '/aggregate_graphs.php');
+ expect($s)->toContain("db_qstr('%'")
+ ->and($s)->not->toContain("LIKE '%\" . trim(\$i)");
+});
+
+test('cli add_graphs uses db_qstr not addslashes for REGEXP (GHSA-mfw8)', function () use ($root) {
+ $s = file_get_contents($root . '/cli/add_graphs.php');
+ expect($s)->not->toContain('addslashes');
+});
+
+test('plugin drop/create confine the table identifier (GHSA-h5wg)', function () use ($root) {
+ $s = file_get_contents($root . '/lib/plugins.php');
+ expect($s)->not->toContain('"DROP TABLE IF EXISTS $table"')
+ ->and($s)->toContain('!preg_match(\'/^[a-zA-Z0-9_]+$/\', $table)');
+});
+
+test('user_admin clamps sort_column to displayed columns (GHSA-m49v)', function () use ($root) {
+ $s = file_get_contents($root . '/user_admin.php');
+ expect($s)->toContain("set_request_var('sort_column', 'username')");
+});
+
+test('user_admin group filter is a bound parameter (GHSA-wfcf)', function () use ($root) {
+ $s = file_get_contents($root . '/user_admin.php');
+ expect($s)->toContain('ug.group_id = ?');
+});
diff --git a/tests/Unit/Security/Xss/XssHardeningRegressionTest.php b/tests/Unit/Security/Xss/XssHardeningRegressionTest.php
new file mode 100644
index 000000000..103be5b76
--- /dev/null
+++ b/tests/Unit/Security/Xss/XssHardeningRegressionTest.php
@@ -0,0 +1,46 @@
+toContain('htmle((string) array_shift($replacement))');
+});
+
+test('report-add device description status is escaped (GHSA-4gph)', function () use ($root) {
+ $s = file_get_contents($root . '/lib/reports.php');
+ expect($s)->not->toContain("__('Device \\'%s\\' successfully added to Report.'");
+});
+
+test('graph export legend columns are html-escaped (GHSA-977w)', function () use ($root) {
+ $s = file_get_contents($root . '/graph_xport.php');
+ expect($s)->toContain('htmle($xport_array');
+});
+
+test('Reports Data Query name is html-escaped (GHSA-mgcq)', function () use ($root) {
+ $s = file_get_contents($root . '/lib/reports.php');
+ expect($s)->toContain('htmle($data_query');
+});
+
+test('die_html_input_error escapes the echoed variable and value (GHSA-5p79)', function () use ($root) {
+ $s = file_get_contents($root . '/lib/html_validate.php');
+ expect($s)->toContain("\$func = CACTI_CLI ? 'trim' : 'htmle';");
+});
+
+test('aggregate rfilter is not echoed into a raw attribute (GHSA-cfhh)', function () use ($root) {
+ $s = file_get_contents($root . '/aggregate_graphs.php');
+ expect($s)->not->toContain("value='