Skip to content
Merged
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
38 changes: 21 additions & 17 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

When generating code for this repository:

1. **Version Compatibility**: This is a Cacti plugin (`slowlog`, version 2.1) targeting Cacti 1.2.32+
1. **Version Compatibility**: This is a Cacti plugin (`slowlog`, version 2.1) targeting Cacti 1.2.29+
2. **Context Files**: Prioritize patterns and standards defined in this file (`.github/copilot-instructions.md`)
3. **Codebase Patterns**: When context files don't provide specific guidance, scan the codebase for established patterns
4. **Architectural Consistency**: Maintain plugin-based architecture extending Cacti core
Expand All @@ -20,26 +20,26 @@ When generating code for this repository:
### Key Dependencies
- Cacti core framework (`api_plugin_*`, `db_*`)
- `js/` chart rendering for slow-query analysis views
- `keywords.txt` reserved-word reference used by the log parser
- `docs/keywords.txt` reserved-word reference used by the log parser

## Project Structure

```
slowlog/ # Repository root (install to plugins/slowlog/ in Cacti)
├── images/ # UI icons
├── includes/ # Library/helper files, require_once'd from the entry points
│ ├── database.php # Schema management: table defs + create/upgrade/drop helpers
│ └── slowlog_functions.php # Log parsing, import, and charting logic
├── js/ # Chart rendering client-side code
├── locales/ # Internationalization files
├── tests/ # Test suite
├── themes/ # CSS theme overlays
├── import_log.php # CLI slow-query-log importer
├── keywords.txt # SQL reserved-word list used by the parser
├── slowlog.php # Main viewer/administration UI
├── INFO # Plugin metadata (name, version, compat)
slowlog/ # Repository root (install to plugins/slowlog/ in Cacti)
├── images/ # UI icons
├── includes/ # Library/helper files, require_once'd from the entry points
│ ├── database.php # Schema management: table defs + create/upgrade/drop helpers
│ └── slowlog_functions.php # Log parsing, import, and charting logic
├── js/ # Chart rendering client-side code
├── locales/ # Internationalization files
├── tests/ # Test suite
├── docs/ # keywords.txt (SQL reserved-word list used by the parser)
├── css/ # CSS theme overlays
├── import_log.php # CLI slow-query-log importer
├── slowlog.php # Main viewer/administration UI
├── INFO # Plugin metadata (name, version, compat)
├── README.md
└── setup.php # Plugin install/uninstall/upgrade hooks
└── setup.php # Plugin install/uninstall/upgrade hooks
```

## Naming Conventions
Expand Down Expand Up @@ -83,7 +83,7 @@ db_execute("DELETE FROM plugin_slowlog WHERE logid = $logid");
```

### Log Import Handling
`import_logfile()`/`slowlog_import()` parse arbitrary uploaded/imported slow-query-log text. Treat log contents as untrusted: never `eval()` or directly execute parsed queries, and use `keywords.txt`-driven tokenizing (`is_reserved_word()`) rather than ad hoc regex that could mis-parse crafted input.
`import_logfile()`/`slowlog_import()` parse arbitrary uploaded/imported slow-query-log text. Treat log contents as untrusted: never `eval()` or directly execute parsed queries, and use `docs/keywords.txt`-driven tokenizing (`is_reserved_word()`) rather than ad hoc regex that could mis-parse crafted input.

### Input Validation
Use `get_filter_request_var()` / `get_nfilter_request_var()` for request input; never read `$_GET`/`$_POST` directly.
Expand Down Expand Up @@ -223,3 +223,7 @@ existing code or adding new code, not just in dedicated cleanup passes:
line, `@param` lines, a blank comment line, then `@return`. Infer parameter/return types from
actual usage; don't change the function's real type-hints in the same pass (let static analysis
flag mismatches separately). Skip vendored third-party library files.

## File manifest & upgrade pruning

The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `slowlog_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change.
3 changes: 3 additions & 0 deletions .github/workflows/plugin-ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ jobs:
- name: Check PHP version
run: php -v

- name: Validate plugin manifest (expected-file drift)
run: php cacti/plugins/slowlog/tests/bin/validate-manifest.php

- name: Run apt-get update
run: sudo apt-get update

Expand Down
2 changes: 1 addition & 1 deletion INFO
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ longname = MySQL/MariaDB Slow Log Viewer
author = The Cacti Group
email =
homepage = http://www.cacti.net
compat = 1.2.32
compat = 1.2.29
Comment thread
TheWitness marked this conversation as resolved.
capabilities = online_view:1, online_mgmt:1, offline_view:0, offline_mgmt:0, remote_collect:0
File renamed without changes.
File renamed without changes.
4 changes: 2 additions & 2 deletions includes/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,8 @@ function slowlog_setup_table_new(): void {

// The (id, word) primary key doesn't prevent duplicate words on a re-run, since id is
// auto-incrementing - only load once, when the table is still empty.
if (file_exists(__DIR__ . '/../keywords.txt') && !db_fetch_cell_prepared('SELECT COUNT(*) FROM plugin_slowlog_reserved_words')) {
$words = file(__DIR__ . '/../keywords.txt') ?: [];
if (file_exists(__DIR__ . '/../docs/keywords.txt') && !db_fetch_cell_prepared('SELECT COUNT(*) FROM plugin_slowlog_reserved_words')) {
$words = file(__DIR__ . '/../docs/keywords.txt') ?: [];

if (cacti_sizeof($words)) {
foreach ($words as $word) {
Expand Down
23 changes: 23 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"tombstones": [
"keywords.txt",
"themes/"
],
"expected": [
"CHANGELOG.md",
"INFO",
"LICENSE",
"README.md",
"css/",
"docs/",
"images/",
"import_log.php",
"includes/",
"js/",
"locales/",
"manifest.json",
"setup.php",
"slowlog.php"
],
"whitelist": []
}
172 changes: 172 additions & 0 deletions setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ function slowlog_check_upgrade(): void {

// The schema create/refresh lives in includes/database.php (the thold model).
slowlog_upgrade_tables();

slowlog_prune_files();
}
}

Expand Down Expand Up @@ -335,3 +337,173 @@ function slowlog_show_tab(): void {
}
}
}

/**
* Removes files and directories that a previous version of this plugin
* shipped but that have since moved or been deleted, using the tombstone
* and whitelist lists in manifest.json. Whitelisted (user-data) paths and
* any VCS metadata (.git*) are never touched; the dev-only tests/ tree is
* removed. Any path that resolves outside the plugin directory (a tampered
* manifest.json) is refused, and any file/directory that cannot be removed
* (e.g. read-only) is reported to the Cacti log. Any top-level entry that is
* neither expected nor a tombstone nor whitelisted is logged to the Cacti
* log and left in place. Called on a plugin version change.
*
* @return void
*
* @global array $config Cacti global configuration array; used to resolve
* the plugin directory.
*/
function slowlog_prune_files(): void {
global $config;

$plugin_dir = $config['base_path'] . '/plugins/slowlog';
$manifest_path = $plugin_dir . '/manifest.json';

if (!is_readable($manifest_path)) {
return;
}

$manifest = json_decode((string) file_get_contents($manifest_path), true);

if (!is_array($manifest)) {
cacti_log('WARNING: slowlog manifest.json could not be parsed; skipping file prune', false, 'SLOWLOG');

return;
}

$tombstones = isset($manifest['tombstones']) && is_array($manifest['tombstones']) ? $manifest['tombstones'] : [];
$expected = isset($manifest['expected']) && is_array($manifest['expected']) ? $manifest['expected'] : [];
$whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : [];

$protected = function (string $rel) use ($whitelist): bool {
if (strncmp($rel, '.git', 4) === 0 || strncmp($rel, '.md', 3) === 0) {
return true;
}

foreach ($whitelist as $entry) {
$entry = trim((string) $entry, '/');

if ($entry !== '' && ($rel === $entry
|| strncmp($rel, $entry . '/', strlen($entry) + 1) === 0
|| strncmp($entry, $rel . '/', strlen($rel) + 1) === 0)) {
return true;
}
}

return false;
};

// Security: resolve the plugin directory so a tampered manifest.json
// cannot steer the prune outside of it.
$plugin_real = realpath($plugin_dir);

// Remove tombstoned (moved/deleted) paths plus the dev-only tests/
// tree and the phpunit.xml test configuration.
$remove = $tombstones;
$remove[] = 'tests/';
$remove[] = 'phpunit.xml';

foreach ($remove as $rel) {
$rel = trim((string) $rel, '/');

if ($rel === '' || $protected($rel)) {
continue;
}
Comment thread
TheWitness marked this conversation as resolved.

// A tombstone must never contain '.'/'..' segments; a tampered manifest
// could use them to escape the plugin directory or target its root.
$segments = explode('/', $rel);

if (in_array('.', $segments, true) || in_array('..', $segments, true)) {
cacti_log(sprintf('WARNING: slowlog prune refused to remove %s: path contains a traversal segment (tampered manifest.json?)', $rel), false, 'SLOWLOG');

continue;
}

$path = $plugin_dir . '/' . $rel;

if (!is_link($path) && !file_exists($path)) {
continue;
}

// Refuse any path that, after resolving symlinks and ../ segments,
// escapes the plugin directory (protects user data from a tampered
// manifest.json).
$anchor = is_link($path) ? dirname($path) : $path;
$real = realpath($anchor);

if ($real === false || ($real !== $plugin_real && strncmp($real, $plugin_real . DIRECTORY_SEPARATOR, strlen((string) $plugin_real) + 1) !== 0)) {
cacti_log(sprintf('WARNING: slowlog prune refused to remove %s: path resolves outside the plugin directory (tampered manifest.json?)', $rel), false, 'SLOWLOG');

continue;
}

if (is_dir($path) && !is_link($path)) {
$removed = slowlog_rmtree($path);
} else {
$removed = @unlink($path);
}

if (!$removed) {
cacti_log(sprintf('WARNING: slowlog upgrade could not remove %s (check file/directory permissions)', $rel), false, 'SLOWLOG');
}
}

// Surface any top-level entry the manifest does not account for.
$known = [];

foreach (array_merge($expected, $tombstones) as $entry) {
$top = explode('/', trim((string) $entry, '/'))[0];

if ($top !== '') {
$known[$top] = true;
}
}

$entries = scandir($plugin_dir);

foreach (($entries !== false ? $entries : []) as $entry) {
if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml' || $protected($entry) || isset($known[$entry])) {
continue;
}

cacti_log(sprintf('WARNING: slowlog upgrade found a file/directory not described in manifest.json: %s (left in place)', $entry), false, 'SLOWLOG');
}
}

/**
* Recursively deletes a directory and its contents. Symlinks are removed
* without being followed. Helper for slowlog_prune_files().
*
* @param string $dir Absolute path to the directory to remove.
*
* @return bool True if the directory and everything under it was removed;
* false if any entry could not be deleted.
*/
function slowlog_rmtree(string $dir): bool {
$entries = scandir($dir);
$ok = true;

foreach (($entries !== false ? $entries : []) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}

$path = $dir . '/' . $entry;

if (is_dir($path) && !is_link($path)) {
if (!slowlog_rmtree($path)) {
$ok = false;
}
} elseif (!@unlink($path)) {
$ok = false;
}
}

if (!@rmdir($dir)) {
$ok = false;
}

return $ok;
}
8 changes: 4 additions & 4 deletions slowlog.php
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,8 @@ function slowlog_import(): void {

print get_md5_include_js('plugins/slowlog/js/apexcharts.js');

if (file_exists($config['base_path'] . "/plugins/slowlog/themes/$selected_theme/apexcharts.css")) {
print get_md5_include_css("plugins/slowlog/themes/$selected_theme/apexcharts.css");
if (file_exists($config['base_path'] . "/plugins/slowlog/css/{$selected_theme}_apexcharts.css")) {
print get_md5_include_css("plugins/slowlog/css/{$selected_theme}_apexcharts.css");
} else {
print '<link href="' . html_escape($config['url_path'] . 'plugins/slowlog/js/apexcharts.css') . '" type="text/css" rel="stylesheet">';
}
Expand Down Expand Up @@ -1036,8 +1036,8 @@ function slowlog_view_charts(string $method): void {

print get_md5_include_js('plugins/slowlog/js/apexcharts.js');

if (file_exists($config['base_path'] . "/plugins/slowlog/themes/$selected_theme/apexcharts.css")) {
print get_md5_include_css("plugins/slowlog/themes/$selected_theme/apexcharts.css");
if (file_exists($config['base_path'] . "/plugins/slowlog/css/{$selected_theme}_apexcharts.css")) {
print get_md5_include_css("plugins/slowlog/css/{$selected_theme}_apexcharts.css");
} else {
print '<link href="' . html_escape($config['url_path'] . 'plugins/slowlog/js/apexcharts.css') . '" type="text/css" rel="stylesheet">';
}
Expand Down
Loading
Loading