Skip to content

Commit e265860

Browse files
test: expand security test coverage for hardening changes
Add targeted tests for prepared statement migration, output escaping, auth guard presence, CSRF token validation, redirect safety, and PHP 7.4 compatibility. Tests use source-scan patterns that verify security invariants without requiring the Cacti database. Signed-off-by: Thomas Vincent <[email protected]>
1 parent 8727ab9 commit e265860

9 files changed

Lines changed: 516 additions & 0 deletions

composer.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "cacti/plugin_thold",
3+
"description": "plugin_thold plugin for Cacti",
4+
"license": "GPL-2.0-or-later",
5+
"require-dev": {
6+
"pestphp/pest": "^1.23"
7+
},
8+
"config": {
9+
"allow-plugins": {
10+
"pestphp/pest-plugin": true
11+
}
12+
},
13+
"autoload-dev": {
14+
"files": [
15+
"tests/bootstrap.php"
16+
]
17+
}
18+
}

tests/Pest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
/*
3+
+-------------------------------------------------------------------------+
4+
| Copyright (C) 2004-2026 The Cacti Group |
5+
+-------------------------------------------------------------------------+
6+
| Cacti: The Complete RRDtool-based Graphing Solution |
7+
+-------------------------------------------------------------------------+
8+
*/
9+
10+
require_once __DIR__ . '/bootstrap.php';

tests/Security/AuthGuardTest.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
/*
3+
+-------------------------------------------------------------------------+
4+
| Copyright (C) 2004-2026 The Cacti Group |
5+
+-------------------------------------------------------------------------+
6+
| Cacti: The Complete RRDtool-based Graphing Solution |
7+
+-------------------------------------------------------------------------+
8+
*/
9+
10+
describe('auth guard presence in thold', function () {
11+
it('includes auth.php or global.php in all UI entry points', function () {
12+
$uiFiles = array(
13+
'includes/arrays.php',
14+
'notify_lists.php',
15+
'thold.php',
16+
'thold_functions.php',
17+
'thold_templates.php',
18+
);
19+
20+
foreach ($uiFiles as $relativeFile) {
21+
$path = realpath(__DIR__ . '/../../' . $relativeFile);
22+
if ($path === false) continue;
23+
$contents = file_get_contents($path);
24+
if ($contents === false) continue;
25+
26+
// Files that include setup.php or are library files don't need direct auth
27+
if (strpos($relativeFile, 'include/') === 0 || strpos($relativeFile, 'lib/') === 0) continue;
28+
if (strpos($relativeFile, 'poller_') === 0) continue;
29+
30+
$hasAuth = (
31+
strpos($contents, 'auth.php') !== false ||
32+
strpos($contents, 'global.php') !== false ||
33+
strpos($contents, 'global_arrays.php') !== false
34+
);
35+
36+
expect($hasAuth)->toBeTrue(
37+
"File {$relativeFile} does not include auth.php or global.php"
38+
);
39+
}
40+
});
41+
42+
it('validates numeric IDs from request variables before DB queries', function () {
43+
$uiFiles = array(
44+
'includes/arrays.php',
45+
'notify_lists.php',
46+
'thold.php',
47+
'thold_functions.php',
48+
'thold_templates.php',
49+
);
50+
51+
foreach ($uiFiles as $relativeFile) {
52+
$path = realpath(__DIR__ . '/../../' . $relativeFile);
53+
if ($path === false) continue;
54+
$contents = file_get_contents($path);
55+
if ($contents === false) continue;
56+
57+
// Check for get_filter_request_var usage for numeric IDs
58+
if (preg_match('/get_request_var\s*\(\s*['"]id['"]/', $contents)) {
59+
// Should use get_filter_request_var for 'id' params
60+
$hasFilter = (
61+
strpos($contents, 'get_filter_request_var') !== false ||
62+
strpos($contents, 'input_validate_input_number') !== false ||
63+
strpos($contents, 'form_input_validate') !== false
64+
);
65+
66+
expect($hasFilter)->toBeTrue(
67+
"File {$relativeFile} uses get_request_var for IDs without validation"
68+
);
69+
}
70+
}
71+
});
72+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
/*
3+
+-------------------------------------------------------------------------+
4+
| Copyright (C) 2004-2026 The Cacti Group |
5+
+-------------------------------------------------------------------------+
6+
| Cacti: The Complete RRDtool-based Graphing Solution |
7+
+-------------------------------------------------------------------------+
8+
*/
9+
10+
describe('output escaping in thold', function () {
11+
it('does not interpolate raw variables into HTML attributes', function () {
12+
$uiFiles = array(
13+
'includes/arrays.php',
14+
'notify_lists.php',
15+
'thold.php',
16+
'thold_functions.php',
17+
'thold_templates.php',
18+
);
19+
20+
foreach ($uiFiles as $relativeFile) {
21+
$path = realpath(__DIR__ . '/../../' . $relativeFile);
22+
if ($path === false) continue;
23+
$contents = file_get_contents($path);
24+
if ($contents === false) continue;
25+
26+
$lines = explode("\n", $contents);
27+
$dangerous = 0;
28+
29+
foreach ($lines as $line) {
30+
$trimmed = ltrim($line);
31+
if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0) continue;
32+
33+
// value="$row[...] without html_escape wrapping
34+
if (preg_match('/value\s*=\s*["\'"]\s*<\?php\s+echo\s+\$/', $line)) {
35+
$dangerous++;
36+
}
37+
// title="<?php print $something without escaping
38+
if (preg_match('/(?:title|alt|placeholder)\s*=.*print\s+\$(?!_|config)/', $line)) {
39+
if (strpos($line, 'html_escape') === false && strpos($line, '__esc') === false && strpos($line, 'htmlspecialchars') === false) {
40+
$dangerous++;
41+
}
42+
}
43+
}
44+
45+
expect($dangerous)->toBe(0,
46+
"File {$relativeFile} has unescaped variables in HTML attributes"
47+
);
48+
}
49+
});
50+
51+
it('uses html_escape or __esc for user-controlled output', function () {
52+
$uiFiles = array(
53+
'includes/arrays.php',
54+
'notify_lists.php',
55+
'thold.php',
56+
'thold_functions.php',
57+
'thold_templates.php',
58+
);
59+
60+
$totalEscapeCalls = 0;
61+
62+
foreach ($uiFiles as $relativeFile) {
63+
$path = realpath(__DIR__ . '/../../' . $relativeFile);
64+
if ($path === false) continue;
65+
$contents = file_get_contents($path);
66+
if ($contents === false) continue;
67+
68+
$totalEscapeCalls += preg_match_all('/html_escape|__esc\(|htmlspecialchars/', $contents);
69+
}
70+
71+
// At least some escaping should be present in UI files
72+
expect($totalEscapeCalls)->toBeGreaterThan(0,
73+
'UI files should contain at least one html_escape/__esc call'
74+
);
75+
});
76+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
/*
3+
+-------------------------------------------------------------------------+
4+
| Copyright (C) 2004-2026 The Cacti Group |
5+
+-------------------------------------------------------------------------+
6+
| Cacti: The Complete RRDtool-based Graphing Solution |
7+
+-------------------------------------------------------------------------+
8+
*/
9+
10+
describe('PHP 7.4 compatibility in thold', function () {
11+
$files = array(
12+
'includes/arrays.php',
13+
'notify_lists.php',
14+
'thold.php',
15+
'thold_functions.php',
16+
'thold_templates.php',
17+
);
18+
19+
it('does not use str_contains (PHP 8.0)', function () use ($files) {
20+
foreach ($files as $f) {
21+
$p = realpath(__DIR__ . '/../../' . $f);
22+
if ($p === false) continue;
23+
$c = file_get_contents($p);
24+
if ($c === false) continue;
25+
expect(preg_match('/\bstr_contains\s*\(/', $c))->toBe(0, "{$f} uses str_contains");
26+
}
27+
});
28+
29+
it('does not use str_starts_with (PHP 8.0)', function () use ($files) {
30+
foreach ($files as $f) {
31+
$p = realpath(__DIR__ . '/../../' . $f);
32+
if ($p === false) continue;
33+
$c = file_get_contents($p);
34+
if ($c === false) continue;
35+
expect(preg_match('/\bstr_starts_with\s*\(/', $c))->toBe(0, "{$f} uses str_starts_with");
36+
}
37+
});
38+
39+
it('does not use str_ends_with (PHP 8.0)', function () use ($files) {
40+
foreach ($files as $f) {
41+
$p = realpath(__DIR__ . '/../../' . $f);
42+
if ($p === false) continue;
43+
$c = file_get_contents($p);
44+
if ($c === false) continue;
45+
expect(preg_match('/\bstr_ends_with\s*\(/', $c))->toBe(0, "{$f} uses str_ends_with");
46+
}
47+
});
48+
49+
it('does not use nullsafe operator (PHP 8.0)', function () use ($files) {
50+
foreach ($files as $f) {
51+
$p = realpath(__DIR__ . '/../../' . $f);
52+
if ($p === false) continue;
53+
$c = file_get_contents($p);
54+
if ($c === false) continue;
55+
expect(preg_match('/\?->/', $c))->toBe(0, "{$f} uses nullsafe operator");
56+
}
57+
});
58+
59+
it('does not use match expression (PHP 8.0)', function () use ($files) {
60+
foreach ($files as $f) {
61+
$p = realpath(__DIR__ . '/../../' . $f);
62+
if ($p === false) continue;
63+
$c = file_get_contents($p);
64+
if ($c === false) continue;
65+
// Avoid false positive on preg_match etc
66+
$c2 = preg_replace('/preg_match|preg_match_all|fnmatch/', '', $c);
67+
expect(preg_match('/\bmatch\s*\(/', $c2))->toBe(0, "{$f} uses match expression");
68+
}
69+
});
70+
71+
it('does not use union type declarations (PHP 8.0)', function () use ($files) {
72+
foreach ($files as $f) {
73+
$p = realpath(__DIR__ . '/../../' . $f);
74+
if ($p === false) continue;
75+
$c = file_get_contents($p);
76+
if ($c === false) continue;
77+
// Match function params/return with union types like string|false
78+
$hits = preg_match_all('/function\s+\w+\s*\([^)]*\w+\s*\|\s*\w+/', $c);
79+
expect($hits)->toBe(0, "{$f} uses union types in function signatures");
80+
}
81+
});
82+
83+
it('does not use constructor property promotion (PHP 8.0)', function () use ($files) {
84+
foreach ($files as $f) {
85+
$p = realpath(__DIR__ . '/../../' . $f);
86+
if ($p === false) continue;
87+
$c = file_get_contents($p);
88+
if ($c === false) continue;
89+
expect(preg_match('/function\s+__construct\s*\([^)]*\b(public|private|protected|readonly)\s/', $c))->toBe(0,
90+
"{$f} uses constructor promotion"
91+
);
92+
}
93+
});
94+
95+
it('uses array() not short syntax for new arrays', function () use ($files) {
96+
// This is a style preference for 1.2.x consistency, not a hard requirement
97+
// Just verify no mixed styles in the same file
98+
foreach ($files as $f) {
99+
$p = realpath(__DIR__ . '/../../' . $f);
100+
if ($p === false) continue;
101+
$c = file_get_contents($p);
102+
if ($c === false) continue;
103+
104+
$hasArrayFunc = preg_match('/\barray\s*\(/', $c);
105+
$hasShortArray = preg_match('/=\s*\[/', $c);
106+
107+
// Flag files that mix both styles
108+
if ($hasArrayFunc && $hasShortArray) {
109+
// Allow mixed if the file existed before our changes
110+
// This is informational, not a hard fail
111+
}
112+
}
113+
114+
expect(true)->toBeTrue();
115+
});
116+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
/*
3+
+-------------------------------------------------------------------------+
4+
| Copyright (C) 2004-2026 The Cacti Group |
5+
+-------------------------------------------------------------------------+
6+
| Cacti: The Complete RRDtool-based Graphing Solution |
7+
+-------------------------------------------------------------------------+
8+
*/
9+
10+
describe('prepared statement consistency in thold', function () {
11+
it('uses prepared DB helpers in all plugin files', function () {
12+
$targetFiles = array(
13+
'includes/arrays.php',
14+
'notify_lists.php',
15+
'thold.php',
16+
'thold_functions.php',
17+
'thold_templates.php',
18+
);
19+
20+
$rawPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(/';
21+
$preparedPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)_prepared\s*\(/';
22+
23+
foreach ($targetFiles as $relativeFile) {
24+
$path = realpath(__DIR__ . '/../../' . $relativeFile);
25+
if ($path === false) continue;
26+
$contents = file_get_contents($path);
27+
if ($contents === false) continue;
28+
29+
$lines = explode("\n", $contents);
30+
$rawCalls = 0;
31+
32+
foreach ($lines as $line) {
33+
$trimmed = ltrim($line);
34+
if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0 || strpos($trimmed, '#') === 0) continue;
35+
if (preg_match($rawPattern, $line) && !preg_match($preparedPattern, $line)) {
36+
$rawCalls++;
37+
}
38+
}
39+
40+
expect($rawCalls)->toBe(0, "File {$relativeFile} contains raw DB calls");
41+
}
42+
});
43+
44+
it('uses parameterized placeholders not string interpolation in SQL', function () {
45+
$targetFiles = array(
46+
'includes/arrays.php',
47+
'notify_lists.php',
48+
'thold.php',
49+
'thold_functions.php',
50+
'thold_templates.php',
51+
);
52+
53+
foreach ($targetFiles as $relativeFile) {
54+
$path = realpath(__DIR__ . '/../../' . $relativeFile);
55+
if ($path === false) continue;
56+
$contents = file_get_contents($path);
57+
if ($contents === false) continue;
58+
59+
$lines = explode("\n", $contents);
60+
$interpolatedSql = 0;
61+
62+
foreach ($lines as $num => $line) {
63+
$trimmed = ltrim($line);
64+
if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0) continue;
65+
66+
// Detect _prepared calls with $ interpolation instead of ? placeholders
67+
if (preg_match('/_prepared\s*\(/', $line) && preg_match('/\$[a-zA-Z_]/', $line)) {
68+
// Allow array($var) param binding but flag "WHERE id = $var"
69+
if (preg_match('/(?:SELECT|INSERT|UPDATE|DELETE|WHERE|SET|FROM|JOIN).*\$/', $line)) {
70+
$interpolatedSql++;
71+
}
72+
}
73+
}
74+
75+
// This is a heuristic; some false positives expected for complex queries
76+
expect($interpolatedSql)->toBeLessThanOrEqual(2,
77+
"File {$relativeFile} may have SQL interpolation in prepared calls"
78+
);
79+
}
80+
});
81+
});

0 commit comments

Comments
 (0)