Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/plugin-ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-cmd="mysqladmin ping -u root -pcactiroot"
--health-interval=10s
--health-timeout=5s
--health-retries=3
Expand Down
12 changes: 12 additions & 0 deletions tests/Pest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

// Pest configuration file.

require_once __DIR__ . '/bootstrap.php';
77 changes: 77 additions & 0 deletions tests/Security/Php74CompatibilityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

/*
* Verify plugin source files do not use PHP 8.0+ syntax.
* Cacti 1.2.x plugins must remain compatible with PHP 7.4.
*/
Comment on lines +10 to +13

function thold_security_compatibility_files() {
return [
'includes/database.php',
'includes/polling.php',
'includes/settings.php',
'notify_lists.php',
'notify_queue.php',
'poller_thold.php',
'setup.php',
'thold.php',
'thold_graph.php',
];
}

function thold_security_read_file($relativeFile) {
$path = realpath(__DIR__ . '/../../' . $relativeFile);
expect($path)->not->toBeFalse("Failed to resolve target file path: {$relativeFile}");

$contents = file_get_contents($path);
expect($contents)->not->toBeFalse("Failed to read target file: {$relativeFile}");

return $contents;
}

it('does not use str_contains (PHP 8.0)', function () {
foreach (thold_security_compatibility_files() as $relativeFile) {
$contents = thold_security_read_file($relativeFile);

expect(preg_match('/\bstr_contains\s*\(/', $contents))->toBe(0,
"{$relativeFile} uses str_contains() which requires PHP 8.0"
);
}
});

it('does not use str_starts_with (PHP 8.0)', function () {
foreach (thold_security_compatibility_files() as $relativeFile) {
$contents = thold_security_read_file($relativeFile);

expect(preg_match('/\bstr_starts_with\s*\(/', $contents))->toBe(0,
"{$relativeFile} uses str_starts_with() which requires PHP 8.0"
);
}
});

it('does not use str_ends_with (PHP 8.0)', function () {
foreach (thold_security_compatibility_files() as $relativeFile) {
$contents = thold_security_read_file($relativeFile);

expect(preg_match('/\bstr_ends_with\s*\(/', $contents))->toBe(0,
"{$relativeFile} uses str_ends_with() which requires PHP 8.0"
);
}
});

it('does not use nullsafe operator (PHP 8.0)', function () {
foreach (thold_security_compatibility_files() as $relativeFile) {
$contents = thold_security_read_file($relativeFile);

expect(preg_match('/\?->/', $contents))->toBe(0,
"{$relativeFile} uses nullsafe operator which requires PHP 8.0"
);
}
});
99 changes: 99 additions & 0 deletions tests/Security/PreparedStatementConsistencyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

/*
* Verify files in the hardening stack use prepared helpers when they execute
* obviously variable-interpolated SQL on a single line.
*/

it('raw-interpolated pattern matches known unsafe db call strings', function () {
$pattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(\s*(["\']).*\$[A-Za-z_{]/';

$unsafe = [
'db_execute("SELECT * FROM thold WHERE id=$id")',
"db_fetch_row('SELECT id FROM thold WHERE name=$name')",
'db_fetch_cell("SELECT value FROM thold WHERE host_id=$host_id")',
'db_fetch_assoc("SELECT * FROM thold WHERE template_id={$template_id}")',
];

foreach ($unsafe as $line) {
expect(preg_match($pattern, $line))->toBe(1, "Expected unsafe pattern match on: {$line}");
}
});

it('raw-interpolated pattern does not match safe db call strings', function () {
$pattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(\s*(["\']).*\$[A-Za-z_{]/';

$safe = [
'db_execute_prepared("SELECT * FROM thold WHERE id = ?", array($id))',
'db_fetch_row_prepared("SELECT id FROM thold WHERE name = ?", array($name))',
'db_execute("SELECT * FROM thold WHERE id = ?")',
'db_execute("SELECT COUNT(*) FROM thold")',
];

foreach ($safe as $line) {
expect(preg_match($pattern, $line))->toBe(0, "Expected no pattern match on safe call: {$line}");
}
});

it('comment-skip logic excludes full-line comments from interpolation checks', function () {
$commentLines = [
'// db_execute("SELECT * FROM thold WHERE id=$id")',
' // db_execute("SELECT * FROM thold WHERE id=$id")',
' * db_execute("SELECT * FROM thold WHERE id=$id")',
' * example db_fetch_row("SELECT id FROM thold WHERE host=$host")',
'# db_execute("SELECT * FROM thold WHERE id=$id")',
];

foreach ($commentLines as $line) {
$trimmed = ltrim($line);
$isSkipped = strpos($trimmed, '//') === 0
|| strpos($trimmed, '*') === 0
|| strpos($trimmed, '#') === 0;

expect($isSkipped)->toBeTrue("Full-line comment should be skipped: {$line}");
}
});

it('does not introduce single-line interpolated db_* calls in hardened files', function () {
$targetFiles = [
'poller_thold.php',
'setup.php',
'thold.php',
'thold_graph.php',
];

$rawInterpolatedPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(\s*(["\']).*\$[A-Za-z_{]/';
$preparedPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)_prepared\s*\(/';

foreach ($targetFiles as $relativeFile) {
$path = realpath(__DIR__ . '/../../' . $relativeFile);
expect($path)->not->toBeFalse("Failed to resolve target file path: {$relativeFile}");

$contents = file_get_contents($path);
expect($contents)->not->toBeFalse("Failed to read target file: {$relativeFile}");

$lines = explode("\n", $contents);

foreach ($lines as $lineNumber => $line) {
$trimmed = ltrim($line);

if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0 || strpos($trimmed, '#') === 0) {
continue;
}

$hasInterpolatedRawCall = preg_match($rawInterpolatedPattern, $line) === 1;
$hasPreparedCall = preg_match($preparedPattern, $line) === 1;

expect($hasInterpolatedRawCall && !$hasPreparedCall)->toBeFalse(
sprintf('File %s contains an interpolated raw db_* call at line %d', $relativeFile, $lineNumber + 1)
);
Comment on lines +91 to +96
}
}
});
84 changes: 84 additions & 0 deletions tests/Security/SetupStructureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

// Verify setup.php defines required plugin hooks and info function.

function thold_read_setup_source() {
$setupPath = realpath(__DIR__ . '/../../setup.php');
expect($setupPath)->not->toBeFalse('Failed to resolve setup.php');
expect(is_readable($setupPath))->toBeTrue('setup.php is not readable');

$source = file_get_contents($setupPath);
expect($source)->not->toBeFalse('Failed to read setup.php');

return $source;
}

it('defines plugin_thold_install function', function () {
$source = thold_read_setup_source();
expect($source)->toContain('function plugin_thold_install');
});

it('defines plugin_thold_version function', function () {
$source = thold_read_setup_source();
expect($source)->toContain('function plugin_thold_version');
});

it('defines plugin_thold_uninstall function', function () {
$source = thold_read_setup_source();
expect($source)->toContain('function plugin_thold_uninstall');
});

it('returns version array with name key', function () {
$source = thold_read_setup_source();
expect($source)->toMatch('/[\'\""]name[\'\""]\s*=>/');
});

it('reads plugin metadata from INFO and returns the info section', function () {
$source = thold_read_setup_source();
expect($source)->toContain('parse_ini_file');
expect($source)->toContain("return \$info['info'];");
Comment on lines +43 to +46
});

it('setup.php file exists and is readable at expected path', function () {
$setupPath = __DIR__ . '/../../setup.php';
expect(file_exists($setupPath))->toBeTrue('setup.php does not exist at expected location');
expect(is_readable($setupPath))->toBeTrue('setup.php exists but is not readable');
expect(realpath($setupPath))->not->toBeFalse('realpath() failed to resolve setup.php');
});

it('registers required poller and device hooks via api_plugin_register_hook', function () {
$source = thold_read_setup_source();
expect($source)->toContain("'poller_output'");
expect($source)->toContain("'poller_bottom'");
expect($source)->toContain("'device_action_array'");
expect($source)->toContain("'api_device_save'");
});

it('api_plugin_register_hook calls use at least four arguments', function () {
$source = thold_read_setup_source();
$matches = [];
preg_match_all('/api_plugin_register_hook\s*\([^;)]+\)/', $source, $matches);

expect(count($matches[0]))->toBeGreaterThan(0, 'No api_plugin_register_hook calls found in setup.php');

foreach ($matches[0] as $call) {
$argCount = substr_count($call, ',') + 1;
expect($argCount)->toBeGreaterThanOrEqual(
4,
"Hook call has fewer than 4 arguments: {$call}"
);
}
});

it('api_plugin_register_hook hook names are non-empty string literals', function () {
$source = thold_read_setup_source();
expect($source)->not->toMatch("/api_plugin_register_hook\s*\(\s*[^,]+,\s*''\s*,/");
expect($source)->not->toMatch('/api_plugin_register_hook\s*\(\s*[^,]+,\s*""\s*,/');
});
Loading
Loading